Repository: openboxes/openboxes
Pull request #5079: OBPIH-7017 Import ABC Class in inventory levels
Author: login: jmiranda
Labels: name: domain: frontend
description: Changes or discussions relating to the frontend UI
name: domain: backend
description: Changes or discussions relating to the backend server
name: flag: config change
description: Hilights a pull request that contains a change to the app config
Created: 2025-03-01T15:49:12Z | Merged: 2025-03-05T08:03:19Z

### :sparkles: Description of Change

**Link to GitHub issue or Jira ticket:**
https://pihemr.atlassian.net/browse/OBPIH-7017

**Description:**
This PR refactors the current inventory level importer (with Kelsey and Manon's blessing). The existing importer was written a long time ago and tried to do too much (update product, create product packages, create or update inventory levels, etc). This refactoring focuses on the facility-level inventory level rules. In fact, I've temporarily added logic to skip records that are pinned to internal locations (aka bin replenishment rules) to prevent us from unwittingly overriding these rules (i.e. these rules should not have ABC class assigned to them). 

Here's the current state of the PR

- [x] Allow user to import (update or create) an inventory level with an ABC class
- [x] Allow user to export inventory level data in a format compatible with the import feature
- [x] Test whether the other fields (particularly the fields that reference internal locations) are working properly
- [x] Allow deletion of duplicate inventory levels using InventoryLevelStatus INACTIVE (or a new status if that is currently used)
- [x] Allow user to clear replenishment bin and preferred bin (currently we’re going to require that the user to delete the rule altogether to reset)
- [x] Ensure inventory level lookup takes internal location into account (otherwise we might update the wrong inventory level)
- [x] Ensure we’re not overriding (removing) data that exists when cells are empty (we can add a string like NULL to signify that we want to delete)
- [x] Temporarily add logic to skip bin replenishment rules (any row with internal location set) so we can avoid overriding the wrong inventory level record
- [x] Allow export all inventory level records for a single facility
- [x] Test performance when uploading 100-1000ss of inventory level records at once
- [x] Detect and prevent import of duplicate inventory level records

Next steps 

- [ ] Suppress product availability refresh (this isn't a problem at the moment because it's not triggering a SQL statement)
- [ ] Fix DataServiceTests, add tests for new classes 
- [ ] Add subtask to allow users to export inventory levels for a single location 
---
### :camera: Screenshots & Recordings (optional)

Currently working [OBPIH-7017 Import ABC Class in inventory levels](https://app.screencastify.com/v3/watch/0t6LfxB7hw2d9xL5XHjG) 

Second video demonstrates [Untitled Video February 28, 2025 11:22 PM](https://app.screencastify.com/v3/watch/5ppblULVkG4WXz1oYliq) 




Diff:
diff --git a/grails-app/conf/application.yml b/grails-app/conf/application.yml
index e174af73600..00432adb6a4 100644
--- a/grails-app/conf/application.yml
+++ b/grails-app/conf/application.yml
@@ -158,6 +158,8 @@ grails:
             hal:
               - application/hal+json
               - application/hal+xml
+            xls:
+              - application/vnd.ms-excel
             xml:
               - text/xml
               - application/xml
diff --git a/grails-app/controllers/org/pih/warehouse/UrlMappings.groovy b/grails-app/controllers/org/pih/warehouse/UrlMappings.groovy
index 21e3d1b5d5f..4e02a799df9 100644
--- a/grails-app/controllers/org/pih/warehouse/UrlMappings.groovy
+++ b/grails-app/controllers/org/pih/warehouse/UrlMappings.groovy
@@ -130,6 +130,11 @@ class UrlMappings {
             action = [GET: "list"]
         }
 
+        "/api/facilities/$facilityId/inventory-levels(.$format)?" {
+            controller = "inventoryLevelApi"
+            action = [GET: "list"]
+        }
+
         "/api/locations/locationTypes" {
             controller = { "locationApi" }
             action = [GET: "locationTypes"]
diff --git a/grails-app/controllers/org/pih/warehouse/api/InventoryLevelApiController.groovy b/grails-app/controllers/org/pih/warehouse/api/InventoryLevelApiController.groovy
new file mode 100644
index 00000000000..e5788e9c739
--- /dev/null
+++ b/grails-app/controllers/org/pih/warehouse/api/InventoryLevelApiController.groovy
@@ -0,0 +1,47 @@
+package org.pih.warehouse.api
+
+import grails.converters.JSON
+import org.pih.warehouse.core.DocumentService
+import org.pih.warehouse.core.Location
+import org.pih.warehouse.data.DataService
+import org.pih.warehouse.importer.InventoryLevelImportDataService
+import org.pih.warehouse.inventory.InventoryLevel
+
+class InventoryLevelApiController {
+
+    DataService dataService
+    DocumentService documentService
+    InventoryLevelImportDataService inventoryLevelImportDataService
+
+    def list() {
+        Location facility = Location.get(params.facilityId)
+        if (!facility)
+            throw new IllegalArgumentException("Unable to locate facility with id ${params.facilityId}")
+
+        List inventoryLevels = InventoryLevel.createCriteria().list {
+            eq("inventory", facility.inventory)
+            isNull("internalLocation")
+        }
+
+        withFormat {
+            "xls" {
+                def data = dataService.transformObjects(inventoryLevels, InventoryLevel.PROPERTIES)
+                documentService.generateExcel(response.outputStream, data)
+                response.setHeader 'Content-disposition', "attachment; filename=\"inventory-levels.xls\""
+                response.outputStream.flush()
+                return
+            }
+            "csv" {
+                String text = inventoryLevelImportDataService.exportInventoryLevels(inventoryLevels)
+                response.contentType = "text/csv"
+                response.setHeader("Content-disposition", "attachment; filename=\"inventory-levels.csv\"")
+                render(text)
+                return
+            }
+
+            "*" {
+                render([data: inventoryLevels] as JSON)
+            }
+        }
+    }
+}
diff --git a/grails-app/controllers/org/pih/warehouse/batch/BatchController.groovy b/grails-app/controllers/org/pih/warehouse/batch/BatchController.groovy
index 2bedbc83ee4..545fa600527 100755
--- a/grails-app/controllers/org/pih/warehouse/batch/BatchController.groovy
+++ b/grails-app/controllers/org/pih/warehouse/batch/BatchController.groovy
@@ -11,8 +11,13 @@ package org.pih.warehouse.batch
 
 import grails.core.GrailsApplication
 import org.apache.poi.poifs.filesystem.OfficeXmlFileException
+import org.pih.warehouse.api.GenericApiService
+import org.pih.warehouse.core.DocumentService
 import org.pih.warehouse.core.Location
+import org.pih.warehouse.core.UploadService
+import org.pih.warehouse.data.DataService
 import org.pih.warehouse.importer.CategoryExcelImporter
+import org.pih.warehouse.importer.DataImporter
 import org.pih.warehouse.importer.ImportDataCommand
 import org.pih.warehouse.importer.InventoryExcelImporter
 import org.pih.warehouse.importer.InventoryLevelExcelImporter
@@ -38,16 +43,14 @@ import org.springframework.web.multipart.support.StandardMultipartHttpServletReq
 
 class BatchController {
 
-    def dataService
-    def documentService
-    def inventoryService
+    DataService dataService
+    DocumentService documentService
     GrailsApplication grailsApplication
-    def genericApiService
-    def uploadService
+    GenericApiService genericApiService
+    UploadService uploadService
 
     def index() {}
 
-
     def uploadData(ImportDataCommand command) {
         if (request instanceof DefaultMultipartHttpServletRequest) {
             def uploadFile = request.getFile('xlsFile')
@@ -58,7 +61,6 @@ class BatchController {
         }
     }
 
-
     def downloadExcel() {
         println "Download XLS template " + params
 
@@ -102,7 +104,6 @@ class BatchController {
         }
     }
 
-
     def importData(ImportDataCommand command) {
 
         if ("POST".equals(request.getMethod())) {
@@ -131,71 +132,7 @@ class BatchController {
                 command.filename = localFile.getAbsolutePath()
                 command.location = Location.get(session.warehouse.id)
                 try {
-                    // Need to choose the right importer
-                    switch (command.importType) {
-                        case "category":
-                            dataImporter = new CategoryExcelImporter(command?.filename)
-                            break
-                        case "inventory":
-                            dataImporter = new InventoryExcelImporter(command?.filename)
-                            break
-                        case "inventoryLevel":
-                            dataImporter = new InventoryLevelExcelImporter(command?.filename)
-                            break
-                        case "location":
-                            dataImporter = new LocationExcelImporter(command?.filename)
-                            break
-                        case "person":
-                            dataImporter = new PersonExcelImporter(command?.filename)
-                            break
-                        case "product":
-                            dataImporter = new ProductExcelImporter(command?.filename)
-                            break
-                        case "productAttribute":
-                            dataImporter = new ProductAttributeExcelImporter(command?.filename)
-                            break
-                        case "productCatalog":
-                            dataImporter = new ProductCatalogExcelImporter(command?.filename)
-                            break
-                        case "productCatalogItem":
-                            dataImporter = new ProductCatalogItemExcelImporter(command?.filename)
-                            break
-                        case "productSupplier":
-                            dataImporter = new ProductSupplierExcelImporter(command?.filename)
-                            break
-                        case "productSupplierPreference":
-                            dataImporter = new ProductSupplierPreferenceExcelImporter(command?.filename)
-                            break
-                        case "productSupplierAttribute":
-                            dataImporter = new ProductSupplierAttributeExcelImporter(command?.filename)
-                            break
-                        case "productPackage":
-                            dataImporter = new ProductPackageExcelImporter(command?.filename)
-                            break
-                        case "outboundStockMovement":
-                            dataImporter = new OutboundStockMovementExcelImporter(command?.filename)
-                            break
-                        case "tag":
-                            dataImporter = new TagExcelImporter(command?.filename)
-                            break
-                        case "user":
-                            dataImporter = new UserExcelImporter(command?.filename)
-                            break
-                        case "userLocation":
-                            dataImporter = new UserLocationExcelImporter(command?.filename)
-                            break
-                        case "productAssociation":
-                            dataImporter = new ProductAssociationExcelImporter(command?.filename)
-                            break
-                        case "productSynonym":
-                            dataImporter = new ProductSynonymExcelImporter(command?.filename)
-                            break
-                        case "purchaseOrderActualReadyDate":
-                            dataImporter = new PurchaseOrderActualReadyDateExcelImporter(command?.filename)
-                            break
-                        default:
-                            command.errors.reject("importType", "${warehouse.message(code: 'import.invalidType.message', default: 'Please choose a valid import type')}")
-                    }
+                    dataImporter = createExcelImporter(command)
                 }
                 catch (OfficeXmlFileException e) {
                     log.error("Error with import file " + e.message, e)
@@ -247,4 +184,51 @@ class BatchController {
         }
     }
 
+    DataImporter createExcelImporter(ImportDataCommand command) {
+
+        switch (command.importType) {
+            case "category":
+                return new CategoryExcelImporter(command?.filename)
+            case "inventory":
+                return new InventoryExcelImporter(command?.filename)
+            case "inventoryLevel":
+                return new InventoryLevelExcelImporter(command?.filename)
+            case "location":
+                return new LocationExcelImporter(command?.filename)
+            case "person":
+                return new PersonExcelImporter(command?.filename)
+            case "product":
+                return new ProductExcelImporter(command?.filename)
+            case "productAttribute":
+                return new ProductAttributeExcelImporter(command?.filename)
+            case "productCatalog":
+                return new ProductCatalogExcelImporter(command?.filename)
+            case "productCatalogItem":
+                return new ProductCatalogItemExcelImporter(command?.filename)
+            case "productSupplier":
+                return new ProductSupplierExcelImporter(command?.filename)
+            case "productSupplierPreference":
+                return new ProductSupplierPreferenceExcelImporter(command?.filename)
+            case "productSupplierAttribute":
+                return new ProductSupplierAttributeExcelImporter(command?.filename)
+            case "productPackage":
+                return new ProductPackageExcelImporter(command?.filename)
+            case "outboundStockMovement":
+                return new OutboundStockMovementExcelImporter(command?.filename)
+            case "tag":
+                return new TagExcelImporter(command?.filename)
+            case "user":
+                return new UserExcelImporter(command?.filename)
+            case "userLocation":
+                return new UserLocationExcelImporter(command?.filename)
+            case "productAssociation":
+                return new ProductAssociationExcelImporter(command?.filename)
+            case "productSynonym":
+                return new ProductSynonymExcelImporter(command?.filename)
+            case "purchaseOrderActualReadyDate":
+                return new PurchaseOrderActualReadyDateExcelImporter(command?.filename)
+            default:
+                command.errors.reject("importType", "${warehouse.message(code: 'import.invalidType.message', default: 'Please choose a valid import type')}")
+        }
+    }
 }
diff --git a/grails-app/controllers/org/pih/warehouse/inventory/InventoryLevelController.groovy b/grails-app/controllers/org/pih/warehouse/inventory/InventoryLevelController.groovy
index 97aa7a94bf0..cb6f7694de0 100644
--- a/grails-app/controllers/org/pih/warehouse/inventory/InventoryLevelController.groovy
+++ b/grails-app/controllers/org/pih/warehouse/inventory/InventoryLevelController.groovy
@@ -12,13 +12,15 @@ package org.pih.warehouse.inventory
 import grails.gorm.PagedResultList
 import grails.gorm.transactions.Transactional
 import org.pih.warehouse.core.Location
+import org.pih.warehouse.importer.InventoryLevelImportDataService
 import org.pih.warehouse.product.Product
+import org.pih.warehouse.product.ProductService
 
 @Transactional
 class InventoryLevelController {
 
-    def productService
-    def dataService
+    ProductService productService
+    InventoryLevelImportDataService inventoryLevelImportDataService
 
     static allowedMethods = [save: "POST", update: "POST"]
 
@@ -50,7 +52,7 @@ class InventoryLevelController {
 
         if (params.format && inventoryLevels) {
             def filename = "inventoryLevels.csv"
-            String text = dataService.exportInventoryLevels(inventoryLevels)
+            String text = inventoryLevelImportDataService.exportInventoryLevels(inventoryLevels)
             response.contentType = "text/csv"
             response.setHeader("Content-disposition", "attachment; filename=\"${filename}\"")
             render(contentType: "text/csv", text: text)
@@ -262,9 +264,8 @@ class InventoryLevelController {
             inventoryLevels = InventoryLevel.findAll()
         }
 
-
         if (inventoryLevels) {
-            String text = dataService.exportInventoryLevels(inventoryLevels)
+            String text = inventoryLevelImportDataService.exportInventoryLevels(inventoryLevels)
             response.contentType = "text/csv"
             response.setHeader("Content-disposition", "attachment; filename=\"${filename}.csv\"")
             render(contentType: "text/csv", text: text)
@@ -275,5 +276,4 @@ class InventoryLevelController {
 
     }
 
-
 }
diff --git a/grails-app/controllers/org/pih/warehouse/requisition/RequisitionController.groovy b/grails-app/controllers/org/pih/warehouse/requisition/RequisitionController.groovy
index 28a830d1cef..b91bb011984 100755
--- a/grails-app/controllers/org/pih/warehouse/requisition/RequisitionController.groovy
+++ b/grails-app/controllers/org/pih/warehouse/requisition/RequisitionController.groovy
@@ -583,7 +583,7 @@ class RequisitionController {
             response.setHeader("Content-disposition",
                     "attachment; filename=\"Requisitions-${date ? date.format("yyyyMMdd-hhmmss") : ""}.csv\"")
             response.contentType = "text/csv"
-            def csv = dataService.exportRequisitions(requisitions)
+            def csv = requisitionService.exportRequisitions(requisitions)
             println "export requisitions: " + csv
             render(contentType: "text/csv", text: csv, encoding: "UTF-8")
         } else {
@@ -599,7 +599,7 @@ class RequisitionController {
             response.setHeader("Content-disposition",
                     "attachment; filename=\"Requisitions-${date ? date.format("yyyyMMdd-hhmmss") : ""}.csv\"")
             response.contentType = "text/csv"
-            def csv = dataService.exportRequisitionItems(requisitions)
+            def csv = requisitionService.exportRequisitionItems(requisitions)
             println "export requisitions: " + csv
             render(contentType: "text/csv", text: csv, encoding: "UTF-8")
         } else {
@@ -615,7 +615,7 @@ class RequisitionController {
             response.setHeader("Content-disposition",
                     "attachment; filename=\"Requisitions-${date ? date.format("yyyyMMdd-hhmmss") : ""}.csv\"")
             response.contentType = "text/csv"
-            def csv = dataService.exportRequisitions(requisitions)
+            def csv = requisitionService.exportRequisitions(requisitions)
             println "export requisitions: " + csv
             render(contentType: "text/csv", text: csv, encoding: "UTF-8")
         } else {
diff --git a/grails-app/domain/org/pih/warehouse/inventory/InventoryLevel.groovy b/grails-app/domain/org/pih/warehouse/inventory/InventoryLevel.groovy
index 042f9378e7f..a579fa80e51 100644
--- a/grails-app/domain/org/pih/warehouse/inventory/InventoryLevel.groovy
+++ b/grails-app/domain/org/pih/warehouse/inventory/InventoryLevel.groovy
@@ -10,6 +10,8 @@
 package org.pih.warehouse.inventory
 
 import grails.databinding.BindUsing
+import grails.plugins.csv.CSVWriter
+import org.grails.plugins.excelimport.ExpectedPropertyType
 import org.pih.warehouse.EmptyStringsToNullBinder
 import org.pih.warehouse.core.Location
 import org.pih.warehouse.product.Product
@@ -47,15 +49,15 @@ class InventoryLevel {
     BigDecimal forecastPeriodDays = 30
 
     // Lead time in days (safety stock is lead time days x daily forecast quantity)
-    @BindUsing({ obj, source -> EmptyStringsToNullBinder.bindEmptyStringToNull(source, "expectedLeadTimeDays")})
+    @BindUsing({ obj, source -> EmptyStringsToNullBinder.bindEmptyStringToNull(source, "expectedLeadTimeDays") })
     BigDecimal expectedLeadTimeDays
 
     // Replenishment period in days
-    @BindUsing({ obj, source -> EmptyStringsToNullBinder.bindEmptyStringToNull(source, "replenishmentPeriodDays")})
+    @BindUsing({ obj, source -> EmptyStringsToNullBinder.bindEmptyStringToNull(source, "replenishmentPeriodDays") })
     BigDecimal replenishmentPeriodDays
 
     // Demand time period in days
-    @BindUsing({ obj, source -> EmptyStringsToNullBinder.bindEmptyStringToNull(source, "demandTimePeriodDays")})
+    @BindUsing({ obj, source -> EmptyStringsToNullBinder.bindEmptyStringToNull(source, "demandTimePeriodDays") })
     BigDecimal demandTimePeriodDays
 
     // Preferred bin location
@@ -91,7 +93,7 @@ class InventoryLevel {
     static constraints = {
         status(nullable: true)
         product(nullable: true, unique: ["inventory", "internalLocation"])
-        internalLocation(nullable:true)
+        internalLocation(nullable: true)
         minQuantity(nullable: true, range: 0..2147483646)
         reorderQuantity(nullable: true, range: 0..2147483646)
         maxQuantity(nullable: true, range: 0..2147483646)
@@ -120,4 +122,17 @@ class InventoryLevel {
         return forecastPeriodDays ? Math.ceil(((Double) (forecastQuantity) / forecastPeriodDays) * 30) : (forecastQuantity * 30)
     }
 
+    static PROPERTIES = [
+            productCode          : "product.productCode",
+            productName          : "product.name",
+            facility             : "inventory",
+            status               : "status",
+            internalLocation     : "internalLocation",
+            preferredBinLocation : "preferredBinLocation",
+            replenishmentLocation: "replenishmentLocation",
+            abcClass             : "abcClass",
+            minQuantity          : "minQuantity",
+            reorderQuantity      : "reorderQuantity",
+            maxQuantity          : "maxQuantity"
+    ]
 }
diff --git a/grails-app/services/org/pih/warehouse/data/DataService.groovy b/grails-app/services/org/pih/warehouse/data/DataService.groovy
index 82a55e339fe..bfc8499418c 100755
--- a/grails-app/services/org/pih/warehouse/data/DataService.groovy
+++ b/grails-app/services/org/pih/warehouse/data/DataService.groovy
@@ -42,14 +42,6 @@ import java.text.SimpleDateFormat
 class DataService {
 
     def dataSource
-    def sessionFactory
-    def userService
-
-    private void cleanUpGorm() {
-        def session = sessionFactory.currentSession
-        session.flush()
-        session.clear()
-    }
 
     List executeQuery(String query) {
         return new Sql(dataSource).rows(query)
@@ -81,379 +73,6 @@ class DataService {
         }
     }
 
-    /**
-     * Validate inventory levels
-     */
-    def validateInventoryLevels(ImportDataCommand command) {
-        println "validate inventory levels " + command.filename
-        validateInventoryLevels(command.location, command.filename)
-    }
-
-    /**
-     *
-     * @param location
-     * @param fileName
-     * @return
-     */
-    def validateInventoryLevels(location, fileName) {
-        InventoryLevelExcelImporter importer = new InventoryLevelExcelImporter(fileName)
-        def inventoryLevelList = importer.getData()
-        inventoryLevelList.each { row ->
-            if (!validateInventoryLevel(row)) {
-                return false
-            }
-        }
-        return true
-    }
-
-    def validateInventoryLevel(row) {
-        row.each { key, value ->
-            def expectedType = InventoryLevelExcelImporter.propertyMap.get(key).expectedType
-            switch (expectedType) {
-                case ExpectedPropertyType.IntType:
-                    assert !value || value instanceof Number || value instanceof Boolean, "Value [${value}] for column [${key}] must be a Number or Boolean but was ${value?.class?.name} (" + row + ")."
-                    break
-                case ExpectedPropertyType.DateType:
-                    assert !value || value instanceof Date, "Value [${value}] for column [${key}] must be a Date but was ${value?.class?.name} (" + row + ")."
-                    break
-                default:
-                    break
-            }
-        }
-        return true
-    }
-
-    def importInventoryLevels(ImportDataCommand command) {
-        println "Import inventory levels " + command.filename
-        importInventoryLevels(command.location, command.filename)
-    }
-
-
-    /**
-     * Import all inventory levels found in the file with the given fileName
-     *
-     * @param location
-     * @param fileName
-     * @return
-     */
-    def importInventoryLevels(location, filename) {
-        println "Import inventory levels " + location + " filename " + filename
-        InventoryLevelExcelImporter importer = new InventoryLevelExcelImporter(filename)
-        def inventoryLevelList = importer.getData()
-        inventoryLevelList.eachWithIndex { row, index ->
-            if (validateInventoryLevel(row)) {
-                importInventoryLevel(location, row, index)
-            }
-        }
-        return inventoryLevelList
-    }
-
-
-    /**
-     * Import a single row from the XLS file
-     *
-     * @param location
-     * @param fileName
-     * @return
-     */
-    def importInventoryLevel(location, row, index) {
-        log.info "Import inventory levels " + location + " row " + row + " index " + index
-        def product = findProduct(row)
-
-        // Modify product attributes (name, manufacturer, manufacturerCode, vendor, vendorCode, unitOfMeasure, etc)
-        updateProduct(product, row)
-
-            // Create inventory level for current location, include bin location
-            if (location.inventory) {
-                Location preferredBinLocation = null
-
-                if (row.preferredBinLocation) {
-                    preferredBinLocation = location.getBinLocations().find {
-                        it.name.equalsIgnoreCase(row.preferredBinLocation.trim())
-                    }
-
-                    if (!preferredBinLocation) {
-                        throw new RuntimeException("Bin location ${row.preferredBinLocation} was not found in current location")
-                    }
-                }
-
-                addInventoryLevelToProduct(product, location.inventory, preferredBinLocation, row)
-            }
-
-        // Create product package if UOM and quantity are provided
-        if (row.packageUom && row.packageSize) {
-            addProductPackageToProduct(product, row.packageUom, row.packageSize, row.pricePerPackage)
-        }
-
-        // Save product
-        product.save()
-
-        // Clean up session after 50 products
-        if (index % 50 == 0) {
-            cleanUpGorm()
-        }
-    }
-
-    /**
-     * Add inventory level to product
-     *
-     * @param product
-     * @param inventory
-     * @param preferredBinLocation
-     * @param minQuantity
-     * @param reorderQuantity
-     * @param maxQuantity
-     * @return
-     */
-    def addInventoryLevelToProduct(Product product, Inventory inventory, Location preferredBinLocation, Map row) {
-        findOrCreateInventoryLevel(product, inventory, preferredBinLocation, row)
-    }
-
-    /**
-     * Add product package to product
-     *
-     * @param product
-     * @param uomCode
-     * @param quantity
-     * @param price
-     * @return
-     */
-    def addProductPackageToProduct(product, uomCode, quantity, price) {
-        println "Create or modify product package: " + uomCode + " " + quantity + " " + price
-        if (uomCode) {
-            def productPackage = findOrCreateProductPackage(product, uomCode, quantity as Integer, price as Float)
-            product.addToPackages(productPackage)
-        }
-    }
-
-    /**
-     * Find or create a unit of measure with the given parameters.
-     *
-     * @param uomCode
-     * @return
-     */
-    def findOrCreateUnitOfMeasure(uomCode) {
-        def unitOfMeasure = UnitOfMeasure.findByCode(uomCode)
-        if (!unitOfMeasure) {
-            unitOfMeasure = new UnitOfMeasure()
-            unitOfMeasure.uomClass = findOrCreateQuantityUnitOfMeasureClass()
-            unitOfMeasure.code = uomCode
-            unitOfMeasure.name = uomCode
-            unitOfMeasure.description = uomCode
-            unitOfMeasure.save()
-        }
-        unitOfMeasure = unitOfMeasure.merge()
-        log.info "findOrCreateUnitOfMeasure: ${unitOfMeasure}"
-        return unitOfMeasure
-
-    }
-
-    /**
-     * Find or create a unit of measure class -- we only care about Quantity.
-     *
-     * @return
-     */
-    def findOrCreateQuantityUnitOfMeasureClass() {
-        def unitOfMeasureClass = UnitOfMeasureClass.findByType(UnitOfMeasureType.QUANTITY)
-        if (!unitOfMeasureClass) {
-            unitOfMeasureClass = new UnitOfMeasureClass()
-            unitOfMeasureClass.code = "QTY"
-            unitOfMeasureClass.name = "Quantity"
-            unitOfMeasureClass.description = "Quantity"
-            unitOfMeasureClass.active = true
-            unitOfMeasureClass.type = UnitOfMeasureType.QUANTITY
-            unitOfMeasureClass.save()
-        }
-        log.info "findOrCreateUnitOfMeasureClass: ${unitOfMeasureClass}"
-        unitOfMeasureClass = unitOfMeasureClass.merge()
-
-        return unitOfMeasureClass
-    }
-
-    /**
-     * Find or create an inventory level with the given parameters.
-     *
-     * @param product
-     * @param inventory
-     * @param preferredBinLocation
-     * @param minQuantity
-     * @param reorderQuantity
-     * @param maxQuantity
-     * @return
-     */
-    def findOrCreateInventoryLevel(Product product, Inventory inventory, Location preferredBinLocation, Map row) {
-
-        log.info "Product ${product.productCode} inventory ${inventory} preferred ${row.preferredForReorder}"
-
-        def inventoryLevel = InventoryLevel.findByProductAndInventory(product, inventory)
-        if (!inventoryLevel) {
-            inventoryLevel = new InventoryLevel()
-            inventoryLevel.inventory = inventory
-            product.addToInventoryLevels(inventoryLevel)
-
-        }
-
-        inventoryLevel.status = InventoryStatus.SUPPORTED
-        inventoryLevel.preferredBinLocation = preferredBinLocation
-        inventoryLevel.minQuantity = row.minQuantity
-        inventoryLevel.reorderQuantity = row.reorderQuantity
-        inventoryLevel.maxQuantity = row.maxQuantity
-        inventoryLevel.preferred = Boolean.valueOf(row.preferredForReorder)
-        inventoryLevel.expectedLeadTimeDays = row.expectedLeadTimeDays
-        inventoryLevel.replenishmentPeriodDays = row.replenishmentPeriodDays
-
-        return inventoryLevel
-    }
-
-    /**
-     * Find or create a product package with the given parameters.
-     *
-     * @param product
-     * @param uomCode
-     * @param quantity
-     * @param price
-     * @return
-     */
-    def findOrCreateProductPackage(product, uomCode, quantity, price) {
-        println "findOrCreateProductPackage: ${product} ${uomCode} ${quantity} ${price}"
-        def unitOfMeasure = findOrCreateUnitOfMeasure(uomCode)
-        def criteria = ProductPackage.createCriteria()
-        def productPackage = criteria.get {
-            eq('uom', unitOfMeasure)
-            eq('product', product)
-            eq('quantity', quantity)
-        }
-
-        if (!productPackage) {
-            productPackage = new ProductPackage()
-            productPackage.lastUpdated = new Date()
-            productPackage.dateCreated = new Date()
-        }
-        productPackage.name = unitOfMeasure.code + "/" + quantity
-        productPackage.description = unitOfMeasure.code + "/" + quantity
-        productPackage.product = product
-        productPackage.gtin = ""
-        productPackage.uom = unitOfMeasure
-        if (!productPackage.productPrice && price) {
-            ProductPrice productPrice = new ProductPrice()
-            productPrice.price = price
-            productPrice.save()
-            productPackage.productPrice = productPrice
-        } else if (productPackage.productPrice && price) {
-            productPackage.productPrice.price = price
-        }
-        productPackage.quantity = quantity ?: 1
-        productPackage = productPackage.merge()
-
-        log.info "findOrCreateProductPackage: ${productPackage}"
-
-
-        return productPackage
-    }
-
-    /**
-     * Find or create a product with the given parameters.
-     *
-     * @param product
-     * @param uomCode
-     * @param quantity
-     * @param price
-     * @return
-     */
-    def findOrCreateProduct(row) {
-        def category = findOrCreateCategory(row.category)
-        def productType = findOrCreateProductType(row.productTypeName)
-        def product = Product.findByProductCode(row.productCode)
-        if (!product) {
-            println("Could not find product with product code " + row.productCode)
-            product = new Product()
-            product.category = category
-            product.productType = productType
-            product.productCode = row.productCode
-            product.name = row.productName
-            product.manufacturer = row.manufacturer
-            product.manufacturerCode = row.manufacturerCode
-            product.vendor = row.vendor
-            product.vendorCode = row.vendorCode
-            product.unitOfMeasure = row.unitOfMeasure
-            product.save()
-        }
-        product = product.merge()
-        log.info "findOrCreateProduct: ${product}"
-        return product
-    }
-
-    def findOrCreateProductType(String productTypeName) {
-        def productType = ProductType.findByName(productTypeName)
-        if (!productType) {
-            productType = new ProductType(name: productTypeName, productTypeCode: ProductTypeCode.GOOD)
-            productType.save(failOnError: true)
-        }
-        return productType
-    }
-
-    def findProduct(row) {
-        def product = Product.findByProductCode(row.productCode)
-        if (!product) {
-            throw new RuntimeException("Product ${row.productCode} was not found")
-        }
-        return product
-    }
-
-    /**
-     * Find or create a category with the given categoryName.
-     *
-     * @param product
-     * @param uomCode
-     * @param quantity
-     * @param price
-     * @return
-     */
-    def findOrCreateCategory(categoryName) {
-        def category = Category.findByName(categoryName)
-        if (!category) {
-            println("Could not find category " + categoryName)
-            category = new Category(name: categoryName, rootCategory: Category.getRootCategory())
-            category.save()
-        }
-        log.info "findOrCreateCategory: ${category}"
-        category = category.merge()
-        return category
-    }
-
-
-    /**
-     * Update an existing product with the data in the given row.
-     *
-     * @param product
-     * @param uomCode
-     * @param quantity
-     * @param price
-     * @return
-     */
-    def updateProduct(product, row) {
-        // Change attributes other than name and category if they exist
-        if (row.manufacturer) {
-            product.manufacturer = row.manufacturer
-        }
-        if (row.manufacturerCode) {
-            product.manufacturerCode = row.manufacturerCode
-        }
-        if (row.vendor) {
-            product.vendor = row.vendor
-        }
-        if (row.vendorCode) {
-            product.vendorCode = row.vendorCode
-        }
-        // If the user-entered unit price is different from the current unit price validate the user is allowed to make the change
-        if (row.pricePerUnit) {
-            Float pricePerUnit = getFloat(row.pricePerUnit)
-            if (pricePerUnit != product.pricePerUnit) {
-                userService.assertCurrentUserHasRoleFinance()
-                product.pricePerUnit = pricePerUnit
-            }
-        }
-    }
 
     /**
      * Should use the apache library to handle this.
@@ -471,209 +90,6 @@ class DataService {
         return 0.0
     }
 
-    /**
-     * Testing CSV data import functionality using Sql
-     *
-     * @return
-     */
-    def importData() {
-        def sql = Sql.newInstance("jdbc:mysql://localhost:3306/mydb", "user", "pswd", "com.mysql.cj.jdbc.Driver")
-        def people = sql.dataSet("PERSON")
-        new File("users.csv").splitEachLine(",") { fields ->
-            people.add(
-                    first_name: fields[0],
-                    last_name: fields[1],
-                    email: fields[2]
-            )
-        }
-    }
-
-    String exportInventoryLevels(Collection inventoryLevels) {
-        def sw = new StringWriter()
-        def csv = new CSVWriter(sw, {
-            "Product Code" { it?.productCode }
-            "Product Name" { it?.productName }
-            "Inventory" { it?.inventory }
-            "Status" { it?.status }
-            "Bin Location" { it?.binLocation }
-            "Preferred" { it?.preferred }
-            "Target Bin Location" { it?.internalLocation }
-            "Preferred Putaway Location" { it?.preferredBinLocation }
-            "Default Replenishment Source" { it?.replenishmentLocation }
-            "ABC Class" { it?.abcClass }
-            "Min Quantity" { it?.minQuantity }
-            "Reorder Quantity" { it?.reorderQuantity }
-            "Max Quantity" { it?.maxQuantity }
-            "Forecast Quantity" { it?.forecastQuantity }
-            "Forecast Period" { it?.forecastPeriodDays }
-            "UOM" { it?.unitOfMeasure }
-        })
-        inventoryLevels.each { inventoryLevel ->
-            csv << [
-                    productCode             : inventoryLevel?.product?.productCode,
-                    productName             : inventoryLevel?.product?.displayNameWithLocaleCode,
-                    inventory               : inventoryLevel?.inventory?.warehouse?.name,
-                    status                  : inventoryLevel?.status,
-                    binLocation             : inventoryLevel?.binLocation ?: "",
-                    preferred               : inventoryLevel?.preferred ?: "",
-                    internalLocation        : inventoryLevel?.internalLocation ?: "",
-                    preferredBinLocation    : inventoryLevel?.preferredBinLocation ?: "",
-                    replenishmentLocation   : inventoryLevel?.replenishmentLocation ?: "",
-                    abcClass                : inventoryLevel?.abcClass ?: "",
-                    minQuantity             : inventoryLevel?.minQuantity ?: "",
-                    reorderQuantity         : inventoryLevel?.reorderQuantity ?: "",
-                    maxQuantity             : inventoryLevel?.maxQuantity ?: "",
-                    forecastQuantity        : inventoryLevel?.forecastQuantity ?: "",
-                    forecastPeriodDays      : inventoryLevel?.forecastPeriodDays ?: "",
-                    unitOfMeasure           : inventoryLevel?.product?.unitOfMeasure ?: "EA"
-            ]
-        }
-        return CSVUtils.prependBomToCsvString(csv.writer.toString())
-    }
-
-
-    /**
-     * Export the given requisitions to CSV.
-     *
-     * @param requisitions
-     * @return
-     */
-    String exportRequisitions(requisitions) {
-        def formatDate = new SimpleDateFormat("dd/MMM/yyyy hh:mm:ss")
-        def sw = new StringWriter()
-
-        def csvWriter = new CSVWriter(sw, {
-            "Requisition Number" { it.requisitionNumber }
-            "Status" { it.status }
-            "Type" { it.type }
-            "Class" { it.commodityClass }
-            "Name" { it.name }
-            "Origin" { it.origin }
-            "Destination" { it.destination }
-
-            "Requested by" { it?.requestedBy?.name ?: "" }
-            "Date Requested" { it.dateRequested }
-
-            "Verified" { it?.verifiedBy?.name ?: "" }
-            "Date Verified" { it.dateVerified }
-
-            "Picked" { it?.pickedBy?.name ?: "" }
-            "Date Picked" { it.datePicked }
-
-            "Checked" { it?.checkedBy?.name ?: "" }
-            "Date Checked" { it.dateChecked }
-
-            "Issued" { it?.issuedBy?.name ?: "" }
-            "Date Issued" { it.dateIssued }
-
-            "Created" { it?.createdBy?.name ?: "" }
-            "Date Created" { it.dateCreated }
-
-            "Updated" { it?.updatedBy?.name ?: "" }
-            "Date Updated" { it.lastUpdated }
-        })
-
-        requisitions.each { requisition ->
-            def row = [
-                    requisitionNumber: requisition.requestNumber,
-                    type             : requisition?.type,
-                    commodityClass   : requisition?.commodityClass,
-                    status           : requisition.status,
-                    name             : requisition.name,
-                    origin           : requisition.origin,
-                    destination      : requisition.destination,
-
-                    requestedBy      : requisition.requestedBy,
-                    dateRequested    : requisition.dateRequested ? "${formatDate.format(requisition.dateRequested)}" : "",
-
-                    reviewedBy       : requisition.reviewedBy,
-                    dateReviewed     : requisition.dateReviewed ? "${formatDate.format(requisition.dateReviewed)}" : "",
-
-                    verifiedBy       : requisition.verifiedBy,
-                    dateVerified     : requisition.dateVerified ? "${formatDate.format(requisition.dateVerified)}" : "",
-
-                    checkedBy        : requisition.checkedBy,
-                    dateChecked      : requisition.dateChecked ? "${formatDate.format(requisition.dateChecked)}" : "",
-
-                    deliveredBy      : requisition.deliveredBy,
-                    dateDelivered    : requisition.dateDelivered ? "${formatDate.format(requisition.dateDelivered)}" : "",
-
-                    pickedBy         : requisition?.picklist?.picker,
-                    datePicked       : requisition?.picklist?.datePicked ? "${formatDate.format(requisition?.picklist?.datePicked)}" : "",
-
-                    issuedBy         : requisition.issuedBy,
-                    dateIssued       : requisition.dateIssued ? "${formatDate.format(requisition.dateIssued)}" : "",
-
-                    receivedBy       : requisition.receivedBy,
-                    dateReceived     : requisition.dateReceived ? "${formatDate.format(requisition.dateReceived)}" : "",
-
-                    createdBy        : requisition.createdBy,
-                    dateCreated      : requisition.dateCreated ? "${formatDate.format(requisition.dateCreated)}" : "",
-
-                    updatedBy        : requisition.updatedBy,
-                    lastUpdated      : requisition.lastUpdated ? "${formatDate.format(requisition.lastUpdated)}" : "",
-            ]
-            csvWriter << row
-        }
-        return CSVUtils.prependBomToCsvString(sw.toString())
-    }
-
-    String exportRequisitionItems(requisitions) {
-        def formatDate = new SimpleDateFormat("dd/MMM/yyyy hh:mm:ss")
-        def sw = new StringWriter()
-
-        def csvWriter = new CSVWriter(sw, {
-            "Requisition Number" { it.requisitionNumber }
-            "Status" { it.status }
-            "Type" { it.type }
-            "Class" { it.commodityClass }
-            "Name" { it.name }
-            "Origin" { it.origin }
-            "Destination" { it.destination }
-            "Requested by" { it?.requestedBy?.name }
-            "Date Requested" { it.dateRequested }
-            "Product code" { it.productCode }
-            "Product name" { it.productName }
-            "Status" { it.itemStatus ?: "" }
-            "Requested" { it.quantity ?: "" }
-            "Approved" { it.quantityApproved ?: "" }
-            "Picked" { it.quantityPicked ?: "" }
-            "Canceled" { it.quantityCanceled ?: "" }
-            "Reason Code" { it.reasonCode ?: "" }
-            "Comments" { it.comments ?: "" }
-
-        })
-
-        requisitions.each { requisition ->
-            requisition.requisitionItems.each { requisitionItem ->
-                def row = [
-                        requisitionNumber: requisition.requestNumber,
-                        type             : requisition?.type,
-                        commodityClass   : requisition?.commodityClass,
-                        status           : requisition.status,
-                        name             : requisition.name,
-                        requestedBy      : requisition.requestedBy ?: "",
-                        dateRequested    : requisition.dateRequested ? "${formatDate.format(requisition.dateRequested)}" : "",
-                        origin           : requisition.origin,
-                        destination      : requisition.destination,
-                        productCode      : requisitionItem.product.productCode,
-                        productName      : requisitionItem.product.name,
-                        itemStatus       : requisitionItem.status,
-                        quantity         : requisitionItem.quantity,
-                        quantityCanceled : requisitionItem.quantityCanceled,
-                        quantityApproved : requisitionItem.quantityApproved,
-                        quantityPicked   : requisitionItem.calculateQuantityPicked(),
-                        reasonCode       : requisitionItem.cancelReasonCode,
-                        comments         : requisitionItem.cancelComments,
-
-
-                ]
-                csvWriter << row
-            }
-        }
-        return CSVUtils.prependBomToCsvString(sw.toString())
-    }
-
     def transformObjects(List objects, List includeFields) {
         Map includeFieldsMap = includeFields.inject([:]) { result, includeField ->
             result[includeField] = includeField
diff --git a/grails-app/services/org/pih/warehouse/importer/InventoryLevelImportDataService.groovy b/grails-app/services/org/pih/warehouse/importer/InventoryLevelImportDataService.groovy
index db215dbd739..22d80b08ae4 100644
--- a/grails-app/services/org/pih/warehouse/importer/InventoryLevelImportDataService.groovy
+++ b/grails-app/services/org/pih/warehouse/importer/InventoryLevelImportDataService.groovy
@@ -10,27 +10,231 @@
 package org.pih.warehouse.importer
 
 import grails.gorm.transactions.Transactional
-import org.pih.warehouse.data.DataService
+import grails.plugins.csv.CSVWriter
+import org.grails.plugins.excelimport.ExpectedPropertyType
+import org.pih.warehouse.core.Location
+import org.pih.warehouse.inventory.InventoryLevel
+import org.pih.warehouse.inventory.InventoryStatus
+import org.pih.warehouse.product.Product
 
 @Transactional
 class InventoryLevelImportDataService implements ImportDataService {
-    DataService dataService
 
     @Override
     void validateData(ImportDataCommand command) {
-        println "validate inventory levels " + command.filename
-        command.data.eachWithIndex { row, index ->
-            if (!dataService.validateInventoryLevel(row)) {
-                command.errors.reject("Row ${index + 1}: Failed validation")
+        List validated = []
+        command.data.eachWithIndex { params, index ->
+
+            if (!validateInventoryLevel(params)) {
+                command.errors.reject("Row ${index + 2}: Failed validation")
+            }
+
+            // Detect duplicates in the validated data
+            def count = validated.count { it.facility == params.facility && it.productCode == params.productCode }
+            if (count > 0) {
+                command.errors.reject("Row ${index + 2}: Detected duplicate inventory levels with facility ${params.facility} and product code ${params.productCode}")
             }
+
+            // Add the current row to the list of validated records (used to detect duplicates)
+            validated << params
         }
     }
 
     @Override
     void importData(ImportDataCommand command) {
-        println "Import inventory levels " + command.location + " filename " + command.filename
-        command.data.eachWithIndex { row, index ->
-            dataService.importInventoryLevel(command.location, row, index)
+        command.data.eachWithIndex { Map params, Integer index ->
+            importInventoryLevel(params)
+        }
+    }
+
+    def validateInventoryLevel(Map params) {
+        params.each { key, value ->
+            def expectedType = InventoryLevelExcelImporter.propertyMap.get(key).expectedType
+            switch (expectedType) {
+                case ExpectedPropertyType.IntType:
+                    assert !value || value instanceof Number || value instanceof Boolean, "Value [${value}] for column [${key}] must be a Number or Boolean but was ${value?.class?.name} (" + row + ")."
+                    break
+                case ExpectedPropertyType.DateType:
+                    assert !value || value instanceof Date, "Value [${value}] for column [${key}] must be a Date but was ${value?.class?.name} (" + params + ")."
+                    break
+                default:
+                    break
+            }
+        }
+        return true
+    }
+
+    /**
+     * Import a single row from the XLS file
+     *
+     * @param location
+     * @param fileName
+     * @return
+     */
+    def importInventoryLevel(Map params) {
+
+        Product product = Product.findByProductCode(params.productCode as String)
+        if (!product) {
+            throw new IllegalArgumentException("Product with product code ${params.productCode} was not found")
+        }
+
+        // Validate that the facility exists
+        Location facility = Location.findByName(params.facility as String)
+        if (!facility || !facility.inventory) {
+            throw new IllegalArgumentException("Facility with name ${params.facility} was not found or is not valid")
+        }
+
+        // TODO Any record with a non-empty internal location is a bin replenishment rule rather than an facility-based
+        //  inventory level rule. In order to protect the bin replenishment rules from being overwritten with this
+        //  data import feature, we're going to skip these records for now. The best way to deal with these will be to
+        //  create a separate importer/exporter to handle the bin replenishment rules separately. Or we can remove
+        //  this check and ensure that the following code handles these rules properly (i.e. most of the time the
+        //  bin replenishment only needs internal location, min quantity, reorder quantity, max quantity). Therefore,
+        //  we should create separate command classes to validate the two types of inventory levels.
+        if (params.internalLocation) {
+            log.info "Skipping update to bin replenishment rule ${params}"
+            return
+        }
+
+        // Validate the target internal location
+        // FIXME for now updating the internal location is prevented by the check above. However in the future we
+        //  would like for users to be able to update this field as part of the . One thing to note is that we
+        //  should add protections to avoid users unwittingly changing facility level rules to internal location
+        //  level rules since this might have dramatic effects. Therefore, it's probably best to allow setting the
+        //  internal location property on create. The other locations
+        // params.internalLocation = resolveInternalLocation(facility, params.internalLocation as String)
+
+        // Resolve any objects that require a database lookup
+        params.preferredBinLocation = params.preferredBinLocation ? resolveInternalLocation(facility, params.preferredBinLocation as String) : null
+        params.replenishmentLocation = params.replenishmentLocation ? resolveInternalLocation(facility, params.replenishmentLocation as String) : null
+
+        // Update the inventory level
+        InventoryLevel inventoryLevel = updateOrCreateInventoryLevel(facility, product, params)
+
+        // Delete the inventory level if the status has been set to inactive,
+        if (inventoryLevel.status == InventoryStatus.INACTIVE) {
+            product.removeFromInventoryLevels(inventoryLevel)
+            inventoryLevel.delete()
+            product.save()
+        } else {
+            // Save inventory level
+            if (inventoryLevel.id) {
+                inventoryLevel.save()
+            }
+            // Create a new inventory level
+            else {
+                product.addToInventoryLevels(inventoryLevel)
+                product.save()
+            }
+        }
+    }
+
+    Location resolveInternalLocation(Location facility, String internalLocationName) {
+        Location internalLocation = facility.getInternalLocation(internalLocationName)
+        if (!internalLocation) {
+            throw new IllegalArgumentException("Unable to locate internal location ${internalLocationName} within facility ${facility?.name}")
+        }
+        return internalLocation
+    }
+
+    /**
+     * Create or update inventory level on product
+     *
+     * @param product
+     * @param inventory
+     * @param preferredBinLocation
+     * @param minQuantity
+     * @param reorderQuantity
+     * @param maxQuantity
+     * @return
+     */
+    InventoryLevel updateOrCreateInventoryLevel(Location facility, Product product, Map params) {
+        // FIXME This should handle duplicate inventory level but I'm not exactly sure
+        //  what to do at this point so I'm going to let the user correct duplicates on their own
+        InventoryLevel inventoryLevel = findOrCreateInventoryLevel(facility, product)
+
+        inventoryLevel.status = params.status ? params.status as InventoryStatus : InventoryStatus.SUPPORTED
+        inventoryLevel.abcClass = params.abcClass
+        inventoryLevel.minQuantity = params.minQuantity as Integer
+        inventoryLevel.reorderQuantity = params.reorderQuantity as Integer
+        inventoryLevel.maxQuantity = params.maxQuantity as Integer
+
+        // Don't clear locations if columns in import are null
+        if (params.internalLocation) {
+            inventoryLevel.internalLocation = params.internalLocation as Location
+        }
+        if (params.preferredBinLocation) {
+            inventoryLevel.preferredBinLocation = params.preferredBinLocation as Location
+        }
+        if (params.replenishmentLocation) {
+            inventoryLevel.replenishmentLocation = params.replenishmentLocation as Location
+        }
+
+        return inventoryLevel
+    }
+
+    /**
+     * Find or create an inventory level with the given parameters.
+     *
+     * @param product
+     * @param inventory
+     * @param preferredBinLocation
+     * @param minQuantity
+     * @param reorderQuantity
+     * @param maxQuantity
+     * @return
+     */
+    def findOrCreateInventoryLevel(Location facility, Product product) {
+        InventoryLevel inventoryLevel = InventoryLevel.findByInventoryAndProductAndBinLocationIsNull(facility.inventory, product)
+        if (!inventoryLevel) {
+            inventoryLevel = new InventoryLevel()
+            inventoryLevel.product = product
+            inventoryLevel.inventory = facility.inventory
+        }
+        return inventoryLevel
+    }
+
+    /**
+     * Export the given inventory levels
+     *
+     * @param inventoryLevels
+     * @return
+     */
+    String exportInventoryLevels(Collection inventoryLevels) {
+        StringWriter sw = new StringWriter()
+        CSVWriter csv = new CSVWriter(sw, {
+            "Product Code" { it?.productCode }
+            "Product Name" { it?.productName }
+            "Facility" { it?.inventory }
+            "Status" { it?.status }
+            "Target Bin Location" { it?.internalLocation }
+            "Preferred Putaway Location" { it?.preferredBinLocation }
+            "Default Replenishment Source" { it?.replenishmentLocation }
+            "ABC Class" { it?.abcClass }
+            "Min Quantity" { it?.minQuantity }
+            "Reorder Quantity" { it?.reorderQuantity }
+            "Max Quantity" { it?.maxQuantity }
+        })
+        inventoryLevels.each { inventoryLevel ->
+            csv << [
+                    productCode             : inventoryLevel?.product?.productCode,
+                    productName             : inventoryLevel?.product?.displayNameWithLocaleCode,
+                    inventory               : inventoryLevel?.inventory?.warehouse?.name,
+                    status                  : inventoryLevel?.status,
+                    binLocation             : inventoryLevel?.binLocation ?: "",
+                    preferred               : inventoryLevel?.preferred ?: "",
+                    internalLocation        : inventoryLevel?.internalLocation ?: "",
+                    preferredBinLocation    : inventoryLevel?.preferredBinLocation ?: "",
+                    replenishmentLocation   : inventoryLevel?.replenishmentLocation ?: "",
+                    abcClass                : inventoryLevel?.abcClass ?: "",
+                    minQuantity             : inventoryLevel?.minQuantity ?: "",
+                    reorderQuantity         : inventoryLevel?.reorderQuantity ?: "",
+                    maxQuantity             : inventoryLevel?.maxQuantity ?: "",
+                    forecastQuantity        : inventoryLevel?.forecastQuantity ?: "",
+                    forecastPeriodDays      : inventoryLevel?.forecastPeriodDays ?: "",
+                    unitOfMeasure           : inventoryLevel?.product?.unitOfMeasure ?: "EA"
+            ]
         }
+        return CSVUtils.prependBomToCsvString(csv.writer.toString())
     }
 }
diff --git a/grails-app/services/org/pih/warehouse/requisition/RequisitionService.groovy b/grails-app/services/org/pih/warehouse/requisition/RequisitionService.groovy
index e7537b38ca7..bf1f06670ef 100644
--- a/grails-app/services/org/pih/warehouse/requisition/RequisitionService.groovy
+++ b/grails-app/services/org/pih/warehouse/requisition/RequisitionService.groovy
@@ -11,6 +11,7 @@ package org.pih.warehouse.requisition
 
 import grails.core.GrailsApplication
 import grails.gorm.transactions.Transactional
+import grails.plugins.csv.CSVWriter
 import grails.validation.ValidationException
 import org.grails.plugins.web.taglib.ApplicationTagLib
 import org.joda.time.LocalDate
@@ -26,6 +27,7 @@ import org.pih.warehouse.core.Location
 import org.pih.warehouse.core.Person
 import org.pih.warehouse.core.ReasonCode
 import org.pih.warehouse.core.User
+import org.pih.warehouse.importer.CSVUtils
 import org.pih.warehouse.inventory.Transaction
 import org.pih.warehouse.inventory.TransactionCode
 import org.pih.warehouse.inventory.TransactionEntry
@@ -34,6 +36,8 @@ import org.pih.warehouse.picklist.Picklist
 import org.pih.warehouse.picklist.PicklistItem
 import org.pih.warehouse.product.Product
 
+import java.text.SimpleDateFormat
+
 @Transactional
 class RequisitionService {
 
@@ -977,4 +981,146 @@ class RequisitionService {
         }
         return false
     }
+
+    /**
+     * Export the given requisitions to CSV.
+     *
+     * @param requisitions
+     * @return
+     */
+    String exportRequisitions(requisitions) {
+        def formatDate = new SimpleDateFormat("dd/MMM/yyyy hh:mm:ss")
+        def sw = new StringWriter()
+
+        def csvWriter = new CSVWriter(sw, {
+            "Requisition Number" { it.requisitionNumber }
+            "Status" { it.status }
+            "Type" { it.type }
+            "Class" { it.commodityClass }
+            "Name" { it.name }
+            "Origin" { it.origin }
+            "Destination" { it.destination }
+
+            "Requested by" { it?.requestedBy?.name ?: "" }
+            "Date Requested" { it.dateRequested }
+
+            "Verified" { it?.verifiedBy?.name ?: "" }
+            "Date Verified" { it.dateVerified }
+
+            "Picked" { it?.pickedBy?.name ?: "" }
+            "Date Picked" { it.datePicked }
+
+            "Checked" { it?.checkedBy?.name ?: "" }
+            "Date Checked" { it.dateChecked }
+
+            "Issued" { it?.issuedBy?.name ?: "" }
+            "Date Issued" { it.dateIssued }
+
+            "Created" { it?.createdBy?.name ?: "" }
+            "Date Created" { it.dateCreated }
+
+            "Updated" { it?.updatedBy?.name ?: "" }
+            "Date Updated" { it.lastUpdated }
+        })
+
+        requisitions.each { requisition ->
+            def row = [
+                    requisitionNumber: requisition.requestNumber,
+                    type             : requisition?.type,
+                    commodityClass   : requisition?.commodityClass,
+                    status           : requisition.status,
+                    name             : requisition.name,
+                    origin           : requisition.origin,
+                    destination      : requisition.destination,
+
+                    requestedBy      : requisition.requestedBy,
+                    dateRequested    : requisition.dateRequested ? "${formatDate.format(requisition.dateRequested)}" : "",
+
+                    reviewedBy       : requisition.reviewedBy,
+                    dateReviewed     : requisition.dateReviewed ? "${formatDate.format(requisition.dateReviewed)}" : "",
+
+                    verifiedBy       : requisition.verifiedBy,
+                    dateVerified     : requisition.dateVerified ? "${formatDate.format(requisition.dateVerified)}" : "",
+
+                    checkedBy        : requisition.checkedBy,
+                    dateChecked      : requisition.dateChecked ? "${formatDate.format(requisition.dateChecked)}" : "",
+
+                    deliveredBy      : requisition.deliveredBy,
+                    dateDelivered    : requisition.dateDelivered ? "${formatDate.format(requisition.dateDelivered)}" : "",
+
+                    pickedBy         : requisition?.picklist?.picker,
+                    datePicked       : requisition?.picklist?.datePicked ? "${formatDate.format(requisition?.picklist?.datePicked)}" : "",
+
+                    issuedBy         : requisition.issuedBy,
+                    dateIssued       : requisition.dateIssued ? "${formatDate.format(requisition.dateIssued)}" : "",
+
+                    receivedBy       : requisition.receivedBy,
+                    dateReceived     : requisition.dateReceived ? "${formatDate.format(requisition.dateReceived)}" : "",
+
+                    createdBy        : requisition.createdBy,
+                    dateCreated      : requisition.dateCreated ? "${formatDate.format(requisition.dateCreated)}" : "",
+
+                    updatedBy        : requisition.updatedBy,
+                    lastUpdated      : requisition.lastUpdated ? "${formatDate.format(requisition.lastUpdated)}" : "",
+            ]
+            csvWriter << row
+        }
+        return CSVUtils.prependBomToCsvString(sw.toString())
+    }
+
+    String exportRequisitionItems(requisitions) {
+        def formatDate = new SimpleDateFormat("dd/MMM/yyyy hh:mm:ss")
+        def sw = new StringWriter()
+
+        def csvWriter = new CSVWriter(sw, {
+            "Requisition Number" { it.requisitionNumber }
+            "Status" { it.status }
+            "Type" { it.type }
+            "Class" { it.commodityClass }
+            "Name" { it.name }
+            "Origin" { it.origin }
+            "Destination" { it.destination }
+            "Requested by" { it?.requestedBy?.name }
+            "Date Requested" { it.dateRequested }
+            "Product code" { it.productCode }
+            "Product name" { it.productName }
+            "Status" { it.itemStatus ?: "" }
+            "Requested" { it.quantity ?: "" }
+            "Approved" { it.quantityApproved ?: "" }
+            "Picked" { it.quantityPicked ?: "" }
+            "Canceled" { it.quantityCanceled ?: "" }
+            "Reason Code" { it.reasonCode ?: "" }
+            "Comments" { it.comments ?: "" }
+
+        })
+
+        requisitions.each { requisition ->
+            requisition.requisitionItems.each { requisitionItem ->
+                def row = [
+                        requisitionNumber: requisition.requestNumber,
+                        type             : requisition?.type,
+                        commodityClass   : requisition?.commodityClass,
+                        status           : requisition.status,
+                        name             : requisition.name,
+                        requestedBy      : requisition.requestedBy ?: "",
+                        dateRequested    : requisition.dateRequested ? "${formatDate.format(requisition.dateRequested)}" : "",
+                        origin           : requisition.origin,
+                        destination      : requisition.destination,
+                        productCode      : requisitionItem.product.productCode,
+                        productName      : requisitionItem.product.name,
+                        itemStatus       : requisitionItem.status,
+                        quantity         : requisitionItem.quantity,
+                        quantityCanceled : requisitionItem.quantityCanceled,
+                        quantityApproved : requisitionItem.quantityApproved,
+                        quantityPicked   : requisitionItem.calculateQuantityPicked(),
+                        reasonCode       : requisitionItem.cancelReasonCode,
+                        comments         : requisitionItem.cancelComments,
+                ]
+                csvWriter << row
+            }
+        }
+        return CSVUtils.prependBomToCsvString(sw.toString())
+    }
+
+
 }
diff --git a/grails-app/views/batch/_uploadFileForm.gsp b/grails-app/views/batch/_uploadFileForm.gsp
index 5a6e7f8c32f..6e3acaad114 100755
--- a/grails-app/views/batch/_uploadFileForm.gsp
+++ b/grails-app/views/batch/_uploadFileForm.gsp
@@ -86,6 +86,9 @@
                                 </g:link>
                             </td>
                             <td>
+                                <g:link controller="batch" action="downloadExcel" params="[type:'InventoryLevel']">
+                                    <warehouse:message code="default.download.label" args="[g.message(code:'default.data.label')]"/>
+                                </g:link>
                             </td>
                         </tr>
                         <tr>
diff --git a/src/main/groovy/org/pih/warehouse/importer/InventoryLevelExcelImporter.groovy b/src/main/groovy/org/pih/warehouse/importer/InventoryLevelExcelImporter.groovy
index c82a1cf5d6d..73334b8b3ee 100644
--- a/src/main/groovy/org/pih/warehouse/importer/InventoryLevelExcelImporter.groovy
+++ b/src/main/groovy/org/pih/warehouse/importer/InventoryLevelExcelImporter.groovy
@@ -30,59 +30,34 @@ class InventoryLevelExcelImporter extends AbstractExcelImporter implements DataI
             sheet    : 'Sheet1',
             startRow : 1,
             columnMap: [
-                    'A': 'status',
-                    'B': 'productCode',
-                    'C': 'productName',
-                    'D': 'category',
-                    'E': 'tags',
-                    'F': 'manufacturer',
-                    'G': 'manufacturerCode',
-                    'H': 'vendor',
-                    'I': 'vendorCode',
-                    'J': 'preferredBinLocation',
-                    'K': 'unitOfMeasure',
-                    'L': 'package',
-                    'M': 'packageUom',
-                    'N': 'packageSize',
-                    'O': 'pricePerPackage',
-                    'P': 'pricePerUnit',
-                    'Q': 'minQuantity',
-                    'R': 'reorderQuantity',
-                    'S': 'maxQuantity',
-                    'T': 'currentQuantity',
-                    'U': 'expectedLeadTimeDays',
-                    'V': 'replenishmentPeriodDays',
-                    'X': 'preferredForReorder'
+                    'A': 'productCode',
+                    'B': 'productName',
+                    'C': 'facility',
+                    'D': 'status',
+                    'E': 'internalLocation',
+                    'F': 'preferredBinLocation',
+                    'G': 'replenishmentLocation',
+                    'H': 'abcClass',
+                    'I': 'minQuantity',
+                    'J': 'reorderQuantity',
+                    'K': 'maxQuantity'
             ]
     ]
 
     static Map propertyMap = [
-            status              : ([expectedType: ExpectedPropertyType.StringType, defaultValue: null]),
-            productCode         : ([expectedType: ExpectedPropertyType.StringType, defaultValue: null]),
-            productName         : ([expectedType: ExpectedPropertyType.StringType, defaultValue: null]),
-            tags                : ([expectedType: ExpectedPropertyType.StringType, defaultValue: null]),
-            category            : ([expectedType: ExpectedPropertyType.StringType, defaultValue: null]),
-            manufacturer        : ([expectedType: ExpectedPropertyType.StringType, defaultValue: null]),
-            manufacturerCode    : ([expectedType: ExpectedPropertyType.StringType, defaultValue: null]),
-            vendor              : ([expectedType: ExpectedPropertyType.StringType, defaultValue: null]),
-            vendorCode          : ([expectedType: ExpectedPropertyType.StringType, defaultValue: null]),
-            preferredBinLocation: ([expectedType: ExpectedPropertyType.StringType, defaultValue: null]),
-            unitOfMeasure       : ([expectedType: ExpectedPropertyType.StringType, defaultValue: null]),
-            package             : ([expectedType: ExpectedPropertyType.StringType, defaultValue: null]),
-            packageUom          : ([expectedType: ExpectedPropertyType.StringType, defaultValue: null]),
-            packageSize         : ([expectedType: ExpectedPropertyType.IntType, defaultValue: null]),
-            pricePerPackage     : ([expectedType: ExpectedPropertyType.IntType, defaultValue: null]),
-            pricePerUnit        : ([expectedType: ExpectedPropertyType.IntType, defaultValue: null]),
-            minQuantity         : ([expectedType: ExpectedPropertyType.IntType, defaultValue: null]),
-            reorderQuantity     : ([expectedType: ExpectedPropertyType.IntType, defaultValue: null]),
-            maxQuantity         : ([expectedType: ExpectedPropertyType.IntType, defaultValue: null]),
-            currentQuantity     : ([expectedType: ExpectedPropertyType.IntType, defaultValue: null]),
-            expectedLeadTimeDays : ([expectedType: ExpectedPropertyType.IntType, defaultValue: null]),
-            replenishmentPeriodDays : ([expectedType: ExpectedPropertyType.IntType, defaultValue: null]),
-            preferredForReorder : ([expectedType: ExpectedPropertyType.StringType, defaultValue: null])
+            productCode            : ([expectedType: ExpectedPropertyType.StringType, defaultValue: null]),
+            productName            : ([expectedType: ExpectedPropertyType.StringType, defaultValue: null]),
+            facility               : ([expectedType: ExpectedPropertyType.StringType, defaultValue: null]),
+            status                 : ([expectedType: ExpectedPropertyType.StringType, defaultValue: null]),
+            internalLocation       : ([expectedType: ExpectedPropertyType.StringType, defaultValue: null]),
+            preferredBinLocation   : ([expectedType: ExpectedPropertyType.StringType, defaultValue: null]),
+            replenishmentLocation  : ([expectedType: ExpectedPropertyType.StringType, defaultValue: null]),
+            abcClass               : ([expectedType: ExpectedPropertyType.StringType, defaultValue: null]),
+            minQuantity            : ([expectedType: ExpectedPropertyType.IntType, defaultValue: null]),
+            reorderQuantity        : ([expectedType: ExpectedPropertyType.IntType, defaultValue: null]),
+            maxQuantity            : ([expectedType: ExpectedPropertyType.IntType, defaultValue: null]),
     ]
 
-
     InventoryLevelExcelImporter(String fileName) {
         super()
         read(fileName)


Reviews:
user: login: alannadolny
body: Is there any benefit of using defs everywhere instead of defining a type?
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-03T11:41:45Z
user: login: awalkowiak
body: I skipped a review of the part that was just moved around and focused on the `InventoryLevelImportDataService.importInventoryLevel` and `InventoryLevelImportDataService.updateOrCreateInventoryLevel` (if there is anything specifying that needs a review beside these two, let me know)
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T14:28:31Z
user: login: jmiranda
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-03T23:13:45Z
user: login: jmiranda
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-03T23:14:43Z
user: login: jmiranda
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-03T23:22:01Z
user: login: jmiranda
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-03T23:24:43Z
user: login: jmiranda
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-04T03:31:06Z
user: login: jmiranda
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-04T03:37:28Z
user: login: awalkowiak
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T08:21:46Z
user: login: alannadolny
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-04T10:46:20Z
user: login: alannadolny
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-04T10:48:15Z
user: login: jmiranda
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-04T17:05:00Z
user: login: jmiranda
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-04T17:08:09Z
user: login: jmiranda
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-04T17:10:47Z
user: login: jmiranda
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-04T17:13:24Z
user: login: awalkowiak
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T08:01:21Z

Review comments:
diff_hunk: @@ -12,13 +12,17 @@ package org.pih.warehouse.inventory
 import grails.gorm.PagedResultList
 import grails.gorm.transactions.Transactional
 import org.pih.warehouse.core.Location
+import org.pih.warehouse.data.DataService
+import org.pih.warehouse.importer.InventoryLevelImportDataService
 import org.pih.warehouse.product.Product
+import org.pih.warehouse.product.ProductService
 
 @Transactional
 class InventoryLevelController {
 
-    def productService
-    def dataService
+    InventoryLevelImportDataService inventoryLevelImportDataService
+    ProductService productService
+    //DataService dataService
path: grails-app/controllers/org/pih/warehouse/inventory/InventoryLevelController.groovy
user: login: alannadolny
body: if it not used, you can remove it :smile: 
created_at: 2025-03-03T11:31:01Z
author_association: COLLABORATOR
diff_hunk: @@ -120,4 +122,27 @@ class InventoryLevel {
         return forecastPeriodDays ? Math.ceil(((Double) (forecastQuantity) / forecastPeriodDays) * 30) : (forecastQuantity * 30)
     }
 
+
+    static PROPERTIES = [
+            productCode          : "product.productCode",
+            productName          : "product.name",
+            inventory            : "inventory",
+            status               : "status",
+            binLocation          : "binLocation",
+            preferred            : "preferred",
+            internalLocation     : "internalLocation",
+            preferredBinLocation : "preferredBinLocation",
+            replenishmentLocation: "replenishmentLocation",
+            abcClass             : "abcClass",
+            minQuantity          : "minQuantity",
+            reorderQuantity      : "reorderQuantity",
+            maxQuantity          : "maxQuantity",
+            forecastQuantity     : "forecastQuantity",
+            forecastPeriodDays   : "forecastPeriodDays",
+            "Unit of Measure"    : "product.unitOfMeasure"
+            //expectedLeadTimeDays   : "expectedLeadTimeDays",
+            //replenishmentPeriodDays: "replenishmentPeriodDays",
+            //preferredForReorder    : "preferredForReorder",
path: grails-app/domain/org/pih/warehouse/inventory/InventoryLevel.groovy
user: login: alannadolny
body: Why is it commented?
created_at: 2025-03-03T11:32:40Z
author_association: COLLABORATOR
diff_hunk: @@ -10,27 +10,217 @@
 package org.pih.warehouse.importer
 
 import grails.gorm.transactions.Transactional
-import org.pih.warehouse.data.DataService
+import grails.plugins.csv.CSVWriter
+import org.grails.plugins.excelimport.ExpectedPropertyType
+import org.pih.warehouse.core.Location
+import org.pih.warehouse.inventory.InventoryLevel
+import org.pih.warehouse.inventory.InventoryStatus
+import org.pih.warehouse.product.Product
 
 @Transactional
 class InventoryLevelImportDataService implements ImportDataService {
-    DataService dataService
 
     @Override
     void validateData(ImportDataCommand command) {
-        println "validate inventory levels " + command.filename
+        log.info "validate inventory levels " + command.filename
+
         command.data.eachWithIndex { row, index ->
-            if (!dataService.validateInventoryLevel(row)) {
+            if (!validateInventoryLevel(row)) {
                 command.errors.reject("Row ${index + 1}: Failed validation")
             }
         }
     }
 
     @Override
     void importData(ImportDataCommand command) {
-        println "Import inventory levels " + command.location + " filename " + command.filename
-        command.data.eachWithIndex { row, index ->
-            dataService.importInventoryLevel(command.location, row, index)
+        command.data.eachWithIndex { Map params, Integer index ->
+            importInventoryLevel(params, index)
+        }
+    }
+
+    def validateInventoryLevel(Map params) {
+        params.each { key, value ->
+            def expectedType = InventoryLevelExcelImporter.propertyMap.get(key).expectedType
+            switch (expectedType) {
+                case ExpectedPropertyType.IntType:
+                    assert !value || value instanceof Number || value instanceof Boolean, "Value [${value}] for column [${key}] must be a Number or Boolean but was ${value?.class?.name} (" + row + ")."
+                    break
+                case ExpectedPropertyType.DateType:
+                    assert !value || value instanceof Date, "Value [${value}] for column [${key}] must be a Date but was ${value?.class?.name} (" + params + ")."
+                    break
+                default:
+                    break
+            }
+        }
+        return true
+    }
+
+    /**
+     * Import a single row from the XLS file
+     *
+     * @param location
+     * @param fileName
+     * @return
+     */
+    def importInventoryLevel(Map params, Integer index) {
+        log.info "Import inventory levels(${index}): " + params
+
+        def product = Product.findByProductCode(params.productCode as String)
path: grails-app/services/org/pih/warehouse/importer/InventoryLevelImportDataService.groovy
user: login: alannadolny
body: I think it's type of Product
created_at: 2025-03-03T11:33:22Z
author_association: COLLABORATOR
diff_hunk: @@ -10,27 +10,217 @@
 package org.pih.warehouse.importer
 
 import grails.gorm.transactions.Transactional
-import org.pih.warehouse.data.DataService
+import grails.plugins.csv.CSVWriter
+import org.grails.plugins.excelimport.ExpectedPropertyType
+import org.pih.warehouse.core.Location
+import org.pih.warehouse.inventory.InventoryLevel
+import org.pih.warehouse.inventory.InventoryStatus
+import org.pih.warehouse.product.Product
 
 @Transactional
 class InventoryLevelImportDataService implements ImportDataService {
-    DataService dataService
 
     @Override
     void validateData(ImportDataCommand command) {
-        println "validate inventory levels " + command.filename
+        log.info "validate inventory levels " + command.filename
+
         command.data.eachWithIndex { row, index ->
-            if (!dataService.validateInventoryLevel(row)) {
+            if (!validateInventoryLevel(row)) {
                 command.errors.reject("Row ${index + 1}: Failed validation")
             }
         }
     }
 
     @Override
     void importData(ImportDataCommand command) {
-        println "Import inventory levels " + command.location + " filename " + command.filename
-        command.data.eachWithIndex { row, index ->
-            dataService.importInventoryLevel(command.location, row, index)
+        command.data.eachWithIndex { Map params, Integer index ->
+            importInventoryLevel(params, index)
+        }
+    }
+
+    def validateInventoryLevel(Map params) {
+        params.each { key, value ->
+            def expectedType = InventoryLevelExcelImporter.propertyMap.get(key).expectedType
+            switch (expectedType) {
+                case ExpectedPropertyType.IntType:
+                    assert !value || value instanceof Number || value instanceof Boolean, "Value [${value}] for column [${key}] must be a Number or Boolean but was ${value?.class?.name} (" + row + ")."
+                    break
+                case ExpectedPropertyType.DateType:
+                    assert !value || value instanceof Date, "Value [${value}] for column [${key}] must be a Date but was ${value?.class?.name} (" + params + ")."
+                    break
+                default:
+                    break
+            }
+        }
+        return true
+    }
+
+    /**
+     * Import a single row from the XLS file
+     *
+     * @param location
+     * @param fileName
+     * @return
+     */
+    def importInventoryLevel(Map params, Integer index) {
+        log.info "Import inventory levels(${index}): " + params
+
+        def product = Product.findByProductCode(params.productCode as String)
+        if (!product) {
+            throw new IllegalArgumentException("Product with product code ${params.productCode} was not found")
+        }
+
+        // Validate that the facility exists
+        Location facility = Location.findByName(params.inventory as String)
+        if (!facility || !facility.inventory) {
+            throw new IllegalArgumentException("Facility with name ${params.inventory} was not found or is not valid")
+        }
+
+        // TODO Any record with a non-empty internal location is a bin replenishment rule rather than an facility-based
+        //  inventory level rule. In order to protect the bin replenishment rules from being overwritten with this
+        //  data import feature, we're going to skip these records for now. The best way to deal with these will be to
+        //  create a separate importer/exporter to handle the bin replenishment rules separately. Or we can remove
+        //  this check and ensure that the following code handles these rules properly (i.e. most of the time the
+        //  bin replenishment only needs internal location, min quantity, reorder quantity, max quantity). Therefore,
+        //  we should create separate command classes to validate the two types of inventory levels.
+        if (params.internalLocation) {
+            log.info "Skipping update to bin replenishment rule ${params}"
+            return
+        }
+
+        // Resolve any objects that require a database lookup
+        params.internalLocation = params.internalLocation ? resolveInternalLocation(facility, params.internalLocation as String) : null
+        params.preferredBinLocation = params.preferredBinLocation ? resolveInternalLocation(facility, params.preferredBinLocation as String) : null
+        params.replenishmentLocation = params.replenishmentLocation ? resolveInternalLocation(facility, params.replenishmentLocation as String) : null
+
+        // Update the inventory level
+        InventoryLevel inventoryLevel = updateOrCreateInventoryLevel(facility, product, params)
+
+        // Delete the inventory level if the status has been set to inactive,
+        if (inventoryLevel.status == InventoryStatus.INACTIVE) {
+            product.removeFromInventoryLevels(inventoryLevel)
+            inventoryLevel.delete()
+            product.save()
+        } else {
+            // Save inventory level OR we could return the inventory level and save them all at the end
+            product.addToInventoryLevels(inventoryLevel)
+            inventoryLevel.save()
+            product.save()
+        }
+    }
+
+    Location resolveInternalLocation(Location facility, String internalLocationName) {
+        Location internalLocation = facility.getInternalLocation(internalLocationName)
+        if (!internalLocation) {
+            throw new IllegalArgumentException("Unable to locate internal location ${internalLocationName} within facility ${facility?.name}")
+        }
+        return internalLocation
path: grails-app/services/org/pih/warehouse/importer/InventoryLevelImportDataService.groovy
user: login: alannadolny
body: Can't we handle the lack of `params.internalLocation` here instead of checking it three times when assigning to:
```
params.internalLocation = params.internalLocation ? resolveInternalLocation(facility, params.internalLocation as String) : null
params.preferredBinLocation = params.preferredBinLocation ? resolveInternalLocation(facility, params.preferredBinLocation as String) : null
params.replenishmentLocation = params.replenishmentLocation ? resolveInternalLocation(facility, params.replenishmentLocation as String) : null
```
created_at: 2025-03-03T11:35:55Z
author_association: COLLABORATOR
diff_hunk: @@ -10,27 +10,217 @@
 package org.pih.warehouse.importer
 
 import grails.gorm.transactions.Transactional
-import org.pih.warehouse.data.DataService
+import grails.plugins.csv.CSVWriter
+import org.grails.plugins.excelimport.ExpectedPropertyType
+import org.pih.warehouse.core.Location
+import org.pih.warehouse.inventory.InventoryLevel
+import org.pih.warehouse.inventory.InventoryStatus
+import org.pih.warehouse.product.Product
 
 @Transactional
 class InventoryLevelImportDataService implements ImportDataService {
-    DataService dataService
 
     @Override
     void validateData(ImportDataCommand command) {
-        println "validate inventory levels " + command.filename
+        log.info "validate inventory levels " + command.filename
+
         command.data.eachWithIndex { row, index ->
-            if (!dataService.validateInventoryLevel(row)) {
+            if (!validateInventoryLevel(row)) {
                 command.errors.reject("Row ${index + 1}: Failed validation")
             }
         }
     }
 
     @Override
     void importData(ImportDataCommand command) {
-        println "Import inventory levels " + command.location + " filename " + command.filename
-        command.data.eachWithIndex { row, index ->
-            dataService.importInventoryLevel(command.location, row, index)
+        command.data.eachWithIndex { Map params, Integer index ->
+            importInventoryLevel(params, index)
+        }
+    }
+
+    def validateInventoryLevel(Map params) {
+        params.each { key, value ->
+            def expectedType = InventoryLevelExcelImporter.propertyMap.get(key).expectedType
+            switch (expectedType) {
+                case ExpectedPropertyType.IntType:
+                    assert !value || value instanceof Number || value instanceof Boolean, "Value [${value}] for column [${key}] must be a Number or Boolean but was ${value?.class?.name} (" + row + ")."
+                    break
+                case ExpectedPropertyType.DateType:
+                    assert !value || value instanceof Date, "Value [${value}] for column [${key}] must be a Date but was ${value?.class?.name} (" + params + ")."
+                    break
+                default:
+                    break
+            }
+        }
+        return true
+    }
+
+    /**
+     * Import a single row from the XLS file
+     *
+     * @param location
+     * @param fileName
+     * @return
+     */
+    def importInventoryLevel(Map params, Integer index) {
+        log.info "Import inventory levels(${index}): " + params
+
+        def product = Product.findByProductCode(params.productCode as String)
+        if (!product) {
+            throw new IllegalArgumentException("Product with product code ${params.productCode} was not found")
+        }
+
+        // Validate that the facility exists
+        Location facility = Location.findByName(params.inventory as String)
+        if (!facility || !facility.inventory) {
+            throw new IllegalArgumentException("Facility with name ${params.inventory} was not found or is not valid")
+        }
+
+        // TODO Any record with a non-empty internal location is a bin replenishment rule rather than an facility-based
+        //  inventory level rule. In order to protect the bin replenishment rules from being overwritten with this
+        //  data import feature, we're going to skip these records for now. The best way to deal with these will be to
+        //  create a separate importer/exporter to handle the bin replenishment rules separately. Or we can remove
+        //  this check and ensure that the following code handles these rules properly (i.e. most of the time the
+        //  bin replenishment only needs internal location, min quantity, reorder quantity, max quantity). Therefore,
+        //  we should create separate command classes to validate the two types of inventory levels.
+        if (params.internalLocation) {
+            log.info "Skipping update to bin replenishment rule ${params}"
+            return
+        }
+
+        // Resolve any objects that require a database lookup
+        params.internalLocation = params.internalLocation ? resolveInternalLocation(facility, params.internalLocation as String) : null
+        params.preferredBinLocation = params.preferredBinLocation ? resolveInternalLocation(facility, params.preferredBinLocation as String) : null
+        params.replenishmentLocation = params.replenishmentLocation ? resolveInternalLocation(facility, params.replenishmentLocation as String) : null
+
+        // Update the inventory level
+        InventoryLevel inventoryLevel = updateOrCreateInventoryLevel(facility, product, params)
+
+        // Delete the inventory level if the status has been set to inactive,
+        if (inventoryLevel.status == InventoryStatus.INACTIVE) {
+            product.removeFromInventoryLevels(inventoryLevel)
+            inventoryLevel.delete()
+            product.save()
+        } else {
+            // Save inventory level OR we could return the inventory level and save them all at the end
+            product.addToInventoryLevels(inventoryLevel)
+            inventoryLevel.save()
+            product.save()
+        }
+    }
+
+    Location resolveInternalLocation(Location facility, String internalLocationName) {
+        Location internalLocation = facility.getInternalLocation(internalLocationName)
+        if (!internalLocation) {
+            throw new IllegalArgumentException("Unable to locate internal location ${internalLocationName} within facility ${facility?.name}")
+        }
+        return internalLocation
+    }
+
+    /**
+     * Create or update inventory level on product
+     *
+     * @param product
+     * @param inventory
+     * @param preferredBinLocation
+     * @param minQuantity
+     * @param reorderQuantity
+     * @param maxQuantity
+     * @return
+     */
+    InventoryLevel updateOrCreateInventoryLevel(Location facility, Product product, Map params) {
+        InventoryLevel inventoryLevel = findOrCreateInventoryLevel(facility, product)
+
+        inventoryLevel.status = params.status ? params.status as InventoryStatus : InventoryStatus.SUPPORTED
+        inventoryLevel.abcClass = params.abcClass
+        inventoryLevel.minQuantity = params.minQuantity as Integer
+        inventoryLevel.reorderQuantity = params.reorderQuantity as Integer
+        inventoryLevel.maxQuantity = params.maxQuantity as Integer
+        inventoryLevel.expectedLeadTimeDays = params.expectedLeadTimeDays as Integer
+        inventoryLevel.replenishmentPeriodDays = params.replenishmentPeriodDays as Integer
+
+        // Don't clear locations if columns in import are null
+        if (params.internalLocation) {
+            inventoryLevel.internalLocation = params.internalLocation as Location
+        }
+        if (params.preferredBinLocation) {
+            inventoryLevel.preferredBinLocation = params.preferredBinLocation as Location
+        }
+        if (params.replenishmentLocation) {
+            inventoryLevel.replenishmentLocation = params.replenishmentLocation as Location
+        }
+
+        return inventoryLevel
+    }
+
+    /**
+     * Find or create an inventory level with the given parameters.
+     *
+     * @param product
+     * @param inventory
+     * @param preferredBinLocation
+     * @param minQuantity
+     * @param reorderQuantity
+     * @param maxQuantity
+     * @return
+     */
+    def findOrCreateInventoryLevel(Location facility, Product product) {
+        log.info "Product ${product.productCode} facility ${facility}"
+        def inventoryLevel = InventoryLevel.findByInventoryAndProductAndBinLocationIsNull(facility.inventory, product)
path: grails-app/services/org/pih/warehouse/importer/InventoryLevelImportDataService.groovy
user: login: alannadolny
body: that's type of InventoryLevel
created_at: 2025-03-03T11:36:53Z
author_association: COLLABORATOR
diff_hunk: @@ -10,27 +10,217 @@
 package org.pih.warehouse.importer
 
 import grails.gorm.transactions.Transactional
-import org.pih.warehouse.data.DataService
+import grails.plugins.csv.CSVWriter
+import org.grails.plugins.excelimport.ExpectedPropertyType
+import org.pih.warehouse.core.Location
+import org.pih.warehouse.inventory.InventoryLevel
+import org.pih.warehouse.inventory.InventoryStatus
+import org.pih.warehouse.product.Product
 
 @Transactional
 class InventoryLevelImportDataService implements ImportDataService {
-    DataService dataService
 
     @Override
     void validateData(ImportDataCommand command) {
-        println "validate inventory levels " + command.filename
+        log.info "validate inventory levels " + command.filename
+
         command.data.eachWithIndex { row, index ->
-            if (!dataService.validateInventoryLevel(row)) {
+            if (!validateInventoryLevel(row)) {
                 command.errors.reject("Row ${index + 1}: Failed validation")
             }
         }
     }
 
     @Override
     void importData(ImportDataCommand command) {
-        println "Import inventory levels " + command.location + " filename " + command.filename
-        command.data.eachWithIndex { row, index ->
-            dataService.importInventoryLevel(command.location, row, index)
+        command.data.eachWithIndex { Map params, Integer index ->
+            importInventoryLevel(params, index)
+        }
+    }
+
+    def validateInventoryLevel(Map params) {
+        params.each { key, value ->
+            def expectedType = InventoryLevelExcelImporter.propertyMap.get(key).expectedType
+            switch (expectedType) {
+                case ExpectedPropertyType.IntType:
+                    assert !value || value instanceof Number || value instanceof Boolean, "Value [${value}] for column [${key}] must be a Number or Boolean but was ${value?.class?.name} (" + row + ")."
+                    break
+                case ExpectedPropertyType.DateType:
+                    assert !value || value instanceof Date, "Value [${value}] for column [${key}] must be a Date but was ${value?.class?.name} (" + params + ")."
+                    break
+                default:
+                    break
+            }
+        }
+        return true
+    }
+
+    /**
+     * Import a single row from the XLS file
+     *
+     * @param location
+     * @param fileName
+     * @return
+     */
+    def importInventoryLevel(Map params, Integer index) {
+        log.info "Import inventory levels(${index}): " + params
+
+        def product = Product.findByProductCode(params.productCode as String)
+        if (!product) {
+            throw new IllegalArgumentException("Product with product code ${params.productCode} was not found")
+        }
+
+        // Validate that the facility exists
+        Location facility = Location.findByName(params.inventory as String)
+        if (!facility || !facility.inventory) {
+            throw new IllegalArgumentException("Facility with name ${params.inventory} was not found or is not valid")
+        }
+
+        // TODO Any record with a non-empty internal location is a bin replenishment rule rather than an facility-based
+        //  inventory level rule. In order to protect the bin replenishment rules from being overwritten with this
+        //  data import feature, we're going to skip these records for now. The best way to deal with these will be to
+        //  create a separate importer/exporter to handle the bin replenishment rules separately. Or we can remove
+        //  this check and ensure that the following code handles these rules properly (i.e. most of the time the
+        //  bin replenishment only needs internal location, min quantity, reorder quantity, max quantity). Therefore,
+        //  we should create separate command classes to validate the two types of inventory levels.
+        if (params.internalLocation) {
+            log.info "Skipping update to bin replenishment rule ${params}"
+            return
+        }
+
+        // Resolve any objects that require a database lookup
+        params.internalLocation = params.internalLocation ? resolveInternalLocation(facility, params.internalLocation as String) : null
+        params.preferredBinLocation = params.preferredBinLocation ? resolveInternalLocation(facility, params.preferredBinLocation as String) : null
+        params.replenishmentLocation = params.replenishmentLocation ? resolveInternalLocation(facility, params.replenishmentLocation as String) : null
+
+        // Update the inventory level
+        InventoryLevel inventoryLevel = updateOrCreateInventoryLevel(facility, product, params)
+
+        // Delete the inventory level if the status has been set to inactive,
+        if (inventoryLevel.status == InventoryStatus.INACTIVE) {
+            product.removeFromInventoryLevels(inventoryLevel)
+            inventoryLevel.delete()
+            product.save()
+        } else {
+            // Save inventory level OR we could return the inventory level and save them all at the end
+            product.addToInventoryLevels(inventoryLevel)
+            inventoryLevel.save()
+            product.save()
+        }
+    }
+
+    Location resolveInternalLocation(Location facility, String internalLocationName) {
+        Location internalLocation = facility.getInternalLocation(internalLocationName)
+        if (!internalLocation) {
+            throw new IllegalArgumentException("Unable to locate internal location ${internalLocationName} within facility ${facility?.name}")
+        }
+        return internalLocation
+    }
+
+    /**
+     * Create or update inventory level on product
+     *
+     * @param product
+     * @param inventory
+     * @param preferredBinLocation
+     * @param minQuantity
+     * @param reorderQuantity
+     * @param maxQuantity
+     * @return
+     */
+    InventoryLevel updateOrCreateInventoryLevel(Location facility, Product product, Map params) {
+        InventoryLevel inventoryLevel = findOrCreateInventoryLevel(facility, product)
+
+        inventoryLevel.status = params.status ? params.status as InventoryStatus : InventoryStatus.SUPPORTED
+        inventoryLevel.abcClass = params.abcClass
+        inventoryLevel.minQuantity = params.minQuantity as Integer
+        inventoryLevel.reorderQuantity = params.reorderQuantity as Integer
+        inventoryLevel.maxQuantity = params.maxQuantity as Integer
+        inventoryLevel.expectedLeadTimeDays = params.expectedLeadTimeDays as Integer
+        inventoryLevel.replenishmentPeriodDays = params.replenishmentPeriodDays as Integer
+
+        // Don't clear locations if columns in import are null
+        if (params.internalLocation) {
+            inventoryLevel.internalLocation = params.internalLocation as Location
+        }
+        if (params.preferredBinLocation) {
+            inventoryLevel.preferredBinLocation = params.preferredBinLocation as Location
+        }
+        if (params.replenishmentLocation) {
+            inventoryLevel.replenishmentLocation = params.replenishmentLocation as Location
+        }
+
+        return inventoryLevel
+    }
+
+    /**
+     * Find or create an inventory level with the given parameters.
+     *
+     * @param product
+     * @param inventory
+     * @param preferredBinLocation
+     * @param minQuantity
+     * @param reorderQuantity
+     * @param maxQuantity
+     * @return
+     */
+    def findOrCreateInventoryLevel(Location facility, Product product) {
+        log.info "Product ${product.productCode} facility ${facility}"
+        def inventoryLevel = InventoryLevel.findByInventoryAndProductAndBinLocationIsNull(facility.inventory, product)
+        if (!inventoryLevel) {
+            inventoryLevel = new InventoryLevel()
+            inventoryLevel.product = product
+            inventoryLevel.inventory = facility.inventory
+        }
+        return inventoryLevel
+    }
+
+    /**
+     * Export the given inventory levels
+     *
+     * @param inventoryLevels
+     * @return
+     */
+    String exportInventoryLevels(Collection inventoryLevels) {
+        def sw = new StringWriter()
+        def csv = new CSVWriter(sw, {
path: grails-app/services/org/pih/warehouse/importer/InventoryLevelImportDataService.groovy
user: login: alannadolny
body: those also have types
created_at: 2025-03-03T11:38:13Z
author_association: COLLABORATOR
diff_hunk: @@ -977,4 +981,146 @@ class RequisitionService {
         }
         return false
     }
+
+    /**
+     * Export the given requisitions to CSV.
+     *
+     * @param requisitions
+     * @return
+     */
+    String exportRequisitions(requisitions) {
+        def formatDate = new SimpleDateFormat("dd/MMM/yyyy hh:mm:ss")
path: grails-app/services/org/pih/warehouse/requisition/RequisitionService.groovy
user: login: alannadolny
body: ![image](https://github.com/user-attachments/assets/99f04745-1871-45dd-880c-47aa9df83bdb)

created_at: 2025-03-03T11:39:07Z
author_association: COLLABORATOR
diff_hunk: @@ -10,27 +10,217 @@
 package org.pih.warehouse.importer
 
 import grails.gorm.transactions.Transactional
-import org.pih.warehouse.data.DataService
+import grails.plugins.csv.CSVWriter
+import org.grails.plugins.excelimport.ExpectedPropertyType
+import org.pih.warehouse.core.Location
+import org.pih.warehouse.inventory.InventoryLevel
+import org.pih.warehouse.inventory.InventoryStatus
+import org.pih.warehouse.product.Product
 
 @Transactional
 class InventoryLevelImportDataService implements ImportDataService {
-    DataService dataService
 
     @Override
     void validateData(ImportDataCommand command) {
-        println "validate inventory levels " + command.filename
+        log.info "validate inventory levels " + command.filename
+
         command.data.eachWithIndex { row, index ->
-            if (!dataService.validateInventoryLevel(row)) {
+            if (!validateInventoryLevel(row)) {
                 command.errors.reject("Row ${index + 1}: Failed validation")
             }
         }
     }
 
     @Override
     void importData(ImportDataCommand command) {
-        println "Import inventory levels " + command.location + " filename " + command.filename
-        command.data.eachWithIndex { row, index ->
-            dataService.importInventoryLevel(command.location, row, index)
+        command.data.eachWithIndex { Map params, Integer index ->
+            importInventoryLevel(params, index)
+        }
+    }
+
+    def validateInventoryLevel(Map params) {
+        params.each { key, value ->
+            def expectedType = InventoryLevelExcelImporter.propertyMap.get(key).expectedType
+            switch (expectedType) {
+                case ExpectedPropertyType.IntType:
+                    assert !value || value instanceof Number || value instanceof Boolean, "Value [${value}] for column [${key}] must be a Number or Boolean but was ${value?.class?.name} (" + row + ")."
+                    break
+                case ExpectedPropertyType.DateType:
+                    assert !value || value instanceof Date, "Value [${value}] for column [${key}] must be a Date but was ${value?.class?.name} (" + params + ")."
+                    break
+                default:
+                    break
+            }
+        }
+        return true
+    }
+
+    /**
+     * Import a single row from the XLS file
+     *
+     * @param location
+     * @param fileName
+     * @return
+     */
+    def importInventoryLevel(Map params, Integer index) {
+        log.info "Import inventory levels(${index}): " + params
+
+        def product = Product.findByProductCode(params.productCode as String)
+        if (!product) {
+            throw new IllegalArgumentException("Product with product code ${params.productCode} was not found")
+        }
+
+        // Validate that the facility exists
+        Location facility = Location.findByName(params.inventory as String)
+        if (!facility || !facility.inventory) {
+            throw new IllegalArgumentException("Facility with name ${params.inventory} was not found or is not valid")
+        }
+
+        // TODO Any record with a non-empty internal location is a bin replenishment rule rather than an facility-based
+        //  inventory level rule. In order to protect the bin replenishment rules from being overwritten with this
+        //  data import feature, we're going to skip these records for now. The best way to deal with these will be to
+        //  create a separate importer/exporter to handle the bin replenishment rules separately. Or we can remove
+        //  this check and ensure that the following code handles these rules properly (i.e. most of the time the
+        //  bin replenishment only needs internal location, min quantity, reorder quantity, max quantity). Therefore,
+        //  we should create separate command classes to validate the two types of inventory levels.
+        if (params.internalLocation) {
+            log.info "Skipping update to bin replenishment rule ${params}"
+            return
+        }
+
+        // Resolve any objects that require a database lookup
+        params.internalLocation = params.internalLocation ? resolveInternalLocation(facility, params.internalLocation as String) : null
path: grails-app/services/org/pih/warehouse/importer/InventoryLevelImportDataService.groovy
user: login: awalkowiak
body: Are we landing at this part of the code after the guard clause above? Or we are going to land here in future?
created_at: 2025-03-03T14:23:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -977,4 +981,146 @@ class RequisitionService {
         }
         return false
     }
+
+    /**
+     * Export the given requisitions to CSV.
+     *
+     * @param requisitions
+     * @return
+     */
+    String exportRequisitions(requisitions) {
+        def formatDate = new SimpleDateFormat("dd/MMM/yyyy hh:mm:ss")
path: grails-app/services/org/pih/warehouse/requisition/RequisitionService.groovy
user: login: awalkowiak
body: @alannadolny I think this part was just moved here from the `dataService`, so I am skipping this part
created_at: 2025-03-03T14:26:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -120,4 +122,27 @@ class InventoryLevel {
         return forecastPeriodDays ? Math.ceil(((Double) (forecastQuantity) / forecastPeriodDays) * 30) : (forecastQuantity * 30)
     }
 
+
+    static PROPERTIES = [
+            productCode          : "product.productCode",
+            productName          : "product.name",
+            inventory            : "inventory",
+            status               : "status",
+            binLocation          : "binLocation",
+            preferred            : "preferred",
+            internalLocation     : "internalLocation",
+            preferredBinLocation : "preferredBinLocation",
+            replenishmentLocation: "replenishmentLocation",
+            abcClass             : "abcClass",
+            minQuantity          : "minQuantity",
+            reorderQuantity      : "reorderQuantity",
+            maxQuantity          : "maxQuantity",
+            forecastQuantity     : "forecastQuantity",
+            forecastPeriodDays   : "forecastPeriodDays",
+            "Unit of Measure"    : "product.unitOfMeasure"
+            //expectedLeadTimeDays   : "expectedLeadTimeDays",
+            //replenishmentPeriodDays: "replenishmentPeriodDays",
+            //preferredForReorder    : "preferredForReorder",
path: grails-app/domain/org/pih/warehouse/inventory/InventoryLevel.groovy
user: login: jmiranda
body: Just deciding on whether we needed to keep it or not. 
created_at: 2025-03-03T23:13:45Z
author_association: MEMBER
diff_hunk: @@ -12,13 +12,17 @@ package org.pih.warehouse.inventory
 import grails.gorm.PagedResultList
 import grails.gorm.transactions.Transactional
 import org.pih.warehouse.core.Location
+import org.pih.warehouse.data.DataService
+import org.pih.warehouse.importer.InventoryLevelImportDataService
 import org.pih.warehouse.product.Product
+import org.pih.warehouse.product.ProductService
 
 @Transactional
 class InventoryLevelController {
 
-    def productService
-    def dataService
+    InventoryLevelImportDataService inventoryLevelImportDataService
+    ProductService productService
+    //DataService dataService
path: grails-app/controllers/org/pih/warehouse/inventory/InventoryLevelController.groovy
user: login: jmiranda
body: Yeah I think I commented it out because as part of the refactor and still needed to see whether it was needed. Thanks for the reminder.
created_at: 2025-03-03T23:14:43Z
author_association: MEMBER
diff_hunk: @@ -10,27 +10,217 @@
 package org.pih.warehouse.importer
 
 import grails.gorm.transactions.Transactional
-import org.pih.warehouse.data.DataService
+import grails.plugins.csv.CSVWriter
+import org.grails.plugins.excelimport.ExpectedPropertyType
+import org.pih.warehouse.core.Location
+import org.pih.warehouse.inventory.InventoryLevel
+import org.pih.warehouse.inventory.InventoryStatus
+import org.pih.warehouse.product.Product
 
 @Transactional
 class InventoryLevelImportDataService implements ImportDataService {
-    DataService dataService
 
     @Override
     void validateData(ImportDataCommand command) {
-        println "validate inventory levels " + command.filename
+        log.info "validate inventory levels " + command.filename
+
         command.data.eachWithIndex { row, index ->
-            if (!dataService.validateInventoryLevel(row)) {
+            if (!validateInventoryLevel(row)) {
                 command.errors.reject("Row ${index + 1}: Failed validation")
             }
         }
     }
 
     @Override
     void importData(ImportDataCommand command) {
-        println "Import inventory levels " + command.location + " filename " + command.filename
-        command.data.eachWithIndex { row, index ->
-            dataService.importInventoryLevel(command.location, row, index)
+        command.data.eachWithIndex { Map params, Integer index ->
+            importInventoryLevel(params, index)
+        }
+    }
+
+    def validateInventoryLevel(Map params) {
+        params.each { key, value ->
+            def expectedType = InventoryLevelExcelImporter.propertyMap.get(key).expectedType
+            switch (expectedType) {
+                case ExpectedPropertyType.IntType:
+                    assert !value || value instanceof Number || value instanceof Boolean, "Value [${value}] for column [${key}] must be a Number or Boolean but was ${value?.class?.name} (" + row + ")."
+                    break
+                case ExpectedPropertyType.DateType:
+                    assert !value || value instanceof Date, "Value [${value}] for column [${key}] must be a Date but was ${value?.class?.name} (" + params + ")."
+                    break
+                default:
+                    break
+            }
+        }
+        return true
+    }
+
+    /**
+     * Import a single row from the XLS file
+     *
+     * @param location
+     * @param fileName
+     * @return
+     */
+    def importInventoryLevel(Map params, Integer index) {
+        log.info "Import inventory levels(${index}): " + params
+
+        def product = Product.findByProductCode(params.productCode as String)
path: grails-app/services/org/pih/warehouse/importer/InventoryLevelImportDataService.groovy
user: login: jmiranda
body: This is used to "cast" the value returned from the params map (that was populated by the excel importer). For some reason, the params map always returns an Object instead of the type we've defined in the property map (i.e. String, Integer, Date). So using "as" converts it to the expected type. I wish I didn't have to do it, but I suspect our beef is with the AbstractExcelImporter. 
created_at: 2025-03-03T23:22:00Z
author_association: MEMBER
diff_hunk: @@ -10,27 +10,217 @@
 package org.pih.warehouse.importer
 
 import grails.gorm.transactions.Transactional
-import org.pih.warehouse.data.DataService
+import grails.plugins.csv.CSVWriter
+import org.grails.plugins.excelimport.ExpectedPropertyType
+import org.pih.warehouse.core.Location
+import org.pih.warehouse.inventory.InventoryLevel
+import org.pih.warehouse.inventory.InventoryStatus
+import org.pih.warehouse.product.Product
 
 @Transactional
 class InventoryLevelImportDataService implements ImportDataService {
-    DataService dataService
 
     @Override
     void validateData(ImportDataCommand command) {
-        println "validate inventory levels " + command.filename
+        log.info "validate inventory levels " + command.filename
+
         command.data.eachWithIndex { row, index ->
-            if (!dataService.validateInventoryLevel(row)) {
+            if (!validateInventoryLevel(row)) {
                 command.errors.reject("Row ${index + 1}: Failed validation")
             }
         }
     }
 
     @Override
     void importData(ImportDataCommand command) {
-        println "Import inventory levels " + command.location + " filename " + command.filename
-        command.data.eachWithIndex { row, index ->
-            dataService.importInventoryLevel(command.location, row, index)
+        command.data.eachWithIndex { Map params, Integer index ->
+            importInventoryLevel(params, index)
+        }
+    }
+
+    def validateInventoryLevel(Map params) {
+        params.each { key, value ->
+            def expectedType = InventoryLevelExcelImporter.propertyMap.get(key).expectedType
+            switch (expectedType) {
+                case ExpectedPropertyType.IntType:
+                    assert !value || value instanceof Number || value instanceof Boolean, "Value [${value}] for column [${key}] must be a Number or Boolean but was ${value?.class?.name} (" + row + ")."
+                    break
+                case ExpectedPropertyType.DateType:
+                    assert !value || value instanceof Date, "Value [${value}] for column [${key}] must be a Date but was ${value?.class?.name} (" + params + ")."
+                    break
+                default:
+                    break
+            }
+        }
+        return true
+    }
+
+    /**
+     * Import a single row from the XLS file
+     *
+     * @param location
+     * @param fileName
+     * @return
+     */
+    def importInventoryLevel(Map params, Integer index) {
+        log.info "Import inventory levels(${index}): " + params
+
+        def product = Product.findByProductCode(params.productCode as String)
+        if (!product) {
+            throw new IllegalArgumentException("Product with product code ${params.productCode} was not found")
+        }
+
+        // Validate that the facility exists
+        Location facility = Location.findByName(params.inventory as String)
+        if (!facility || !facility.inventory) {
+            throw new IllegalArgumentException("Facility with name ${params.inventory} was not found or is not valid")
+        }
+
+        // TODO Any record with a non-empty internal location is a bin replenishment rule rather than an facility-based
+        //  inventory level rule. In order to protect the bin replenishment rules from being overwritten with this
+        //  data import feature, we're going to skip these records for now. The best way to deal with these will be to
+        //  create a separate importer/exporter to handle the bin replenishment rules separately. Or we can remove
+        //  this check and ensure that the following code handles these rules properly (i.e. most of the time the
+        //  bin replenishment only needs internal location, min quantity, reorder quantity, max quantity). Therefore,
+        //  we should create separate command classes to validate the two types of inventory levels.
+        if (params.internalLocation) {
+            log.info "Skipping update to bin replenishment rule ${params}"
+            return
+        }
+
+        // Resolve any objects that require a database lookup
+        params.internalLocation = params.internalLocation ? resolveInternalLocation(facility, params.internalLocation as String) : null
+        params.preferredBinLocation = params.preferredBinLocation ? resolveInternalLocation(facility, params.preferredBinLocation as String) : null
+        params.replenishmentLocation = params.replenishmentLocation ? resolveInternalLocation(facility, params.replenishmentLocation as String) : null
+
+        // Update the inventory level
+        InventoryLevel inventoryLevel = updateOrCreateInventoryLevel(facility, product, params)
+
+        // Delete the inventory level if the status has been set to inactive,
+        if (inventoryLevel.status == InventoryStatus.INACTIVE) {
+            product.removeFromInventoryLevels(inventoryLevel)
+            inventoryLevel.delete()
+            product.save()
+        } else {
+            // Save inventory level OR we could return the inventory level and save them all at the end
+            product.addToInventoryLevels(inventoryLevel)
+            inventoryLevel.save()
+            product.save()
+        }
+    }
+
+    Location resolveInternalLocation(Location facility, String internalLocationName) {
+        Location internalLocation = facility.getInternalLocation(internalLocationName)
+        if (!internalLocation) {
+            throw new IllegalArgumentException("Unable to locate internal location ${internalLocationName} within facility ${facility?.name}")
+        }
+        return internalLocation
path: grails-app/services/org/pih/warehouse/importer/InventoryLevelImportDataService.groovy
user: login: jmiranda
body: Can you provide an example for what you mean? Something like this?
```
params.internalLocation = resolveInternalLocation(facility, params.internalLocation as String)
```
created_at: 2025-03-03T23:24:43Z
author_association: MEMBER
diff_hunk: @@ -10,27 +10,217 @@
 package org.pih.warehouse.importer
 
 import grails.gorm.transactions.Transactional
-import org.pih.warehouse.data.DataService
+import grails.plugins.csv.CSVWriter
+import org.grails.plugins.excelimport.ExpectedPropertyType
+import org.pih.warehouse.core.Location
+import org.pih.warehouse.inventory.InventoryLevel
+import org.pih.warehouse.inventory.InventoryStatus
+import org.pih.warehouse.product.Product
 
 @Transactional
 class InventoryLevelImportDataService implements ImportDataService {
-    DataService dataService
 
     @Override
     void validateData(ImportDataCommand command) {
-        println "validate inventory levels " + command.filename
+        log.info "validate inventory levels " + command.filename
+
         command.data.eachWithIndex { row, index ->
-            if (!dataService.validateInventoryLevel(row)) {
+            if (!validateInventoryLevel(row)) {
                 command.errors.reject("Row ${index + 1}: Failed validation")
             }
         }
     }
 
     @Override
     void importData(ImportDataCommand command) {
-        println "Import inventory levels " + command.location + " filename " + command.filename
-        command.data.eachWithIndex { row, index ->
-            dataService.importInventoryLevel(command.location, row, index)
+        command.data.eachWithIndex { Map params, Integer index ->
+            importInventoryLevel(params, index)
+        }
+    }
+
+    def validateInventoryLevel(Map params) {
+        params.each { key, value ->
+            def expectedType = InventoryLevelExcelImporter.propertyMap.get(key).expectedType
+            switch (expectedType) {
+                case ExpectedPropertyType.IntType:
+                    assert !value || value instanceof Number || value instanceof Boolean, "Value [${value}] for column [${key}] must be a Number or Boolean but was ${value?.class?.name} (" + row + ")."
+                    break
+                case ExpectedPropertyType.DateType:
+                    assert !value || value instanceof Date, "Value [${value}] for column [${key}] must be a Date but was ${value?.class?.name} (" + params + ")."
+                    break
+                default:
+                    break
+            }
+        }
+        return true
+    }
+
+    /**
+     * Import a single row from the XLS file
+     *
+     * @param location
+     * @param fileName
+     * @return
+     */
+    def importInventoryLevel(Map params, Integer index) {
+        log.info "Import inventory levels(${index}): " + params
+
+        def product = Product.findByProductCode(params.productCode as String)
path: grails-app/services/org/pih/warehouse/importer/InventoryLevelImportDataService.groovy
user: login: jmiranda
body: Sorry, just realized this was one of the places where you are pointing out the use of "def". Yeah this was existing code that I forgot to change. Will push a fix later.
created_at: 2025-03-04T03:31:06Z
author_association: MEMBER
diff_hunk: @@ -10,27 +10,217 @@
 package org.pih.warehouse.importer
 
 import grails.gorm.transactions.Transactional
-import org.pih.warehouse.data.DataService
+import grails.plugins.csv.CSVWriter
+import org.grails.plugins.excelimport.ExpectedPropertyType
+import org.pih.warehouse.core.Location
+import org.pih.warehouse.inventory.InventoryLevel
+import org.pih.warehouse.inventory.InventoryStatus
+import org.pih.warehouse.product.Product
 
 @Transactional
 class InventoryLevelImportDataService implements ImportDataService {
-    DataService dataService
 
     @Override
     void validateData(ImportDataCommand command) {
-        println "validate inventory levels " + command.filename
+        log.info "validate inventory levels " + command.filename
+
         command.data.eachWithIndex { row, index ->
-            if (!dataService.validateInventoryLevel(row)) {
+            if (!validateInventoryLevel(row)) {
                 command.errors.reject("Row ${index + 1}: Failed validation")
             }
         }
     }
 
     @Override
     void importData(ImportDataCommand command) {
-        println "Import inventory levels " + command.location + " filename " + command.filename
-        command.data.eachWithIndex { row, index ->
-            dataService.importInventoryLevel(command.location, row, index)
+        command.data.eachWithIndex { Map params, Integer index ->
+            importInventoryLevel(params, index)
+        }
+    }
+
+    def validateInventoryLevel(Map params) {
+        params.each { key, value ->
+            def expectedType = InventoryLevelExcelImporter.propertyMap.get(key).expectedType
+            switch (expectedType) {
+                case ExpectedPropertyType.IntType:
+                    assert !value || value instanceof Number || value instanceof Boolean, "Value [${value}] for column [${key}] must be a Number or Boolean but was ${value?.class?.name} (" + row + ")."
+                    break
+                case ExpectedPropertyType.DateType:
+                    assert !value || value instanceof Date, "Value [${value}] for column [${key}] must be a Date but was ${value?.class?.name} (" + params + ")."
+                    break
+                default:
+                    break
+            }
+        }
+        return true
+    }
+
+    /**
+     * Import a single row from the XLS file
+     *
+     * @param location
+     * @param fileName
+     * @return
+     */
+    def importInventoryLevel(Map params, Integer index) {
+        log.info "Import inventory levels(${index}): " + params
+
+        def product = Product.findByProductCode(params.productCode as String)
+        if (!product) {
+            throw new IllegalArgumentException("Product with product code ${params.productCode} was not found")
+        }
+
+        // Validate that the facility exists
+        Location facility = Location.findByName(params.inventory as String)
+        if (!facility || !facility.inventory) {
+            throw new IllegalArgumentException("Facility with name ${params.inventory} was not found or is not valid")
+        }
+
+        // TODO Any record with a non-empty internal location is a bin replenishment rule rather than an facility-based
+        //  inventory level rule. In order to protect the bin replenishment rules from being overwritten with this
+        //  data import feature, we're going to skip these records for now. The best way to deal with these will be to
+        //  create a separate importer/exporter to handle the bin replenishment rules separately. Or we can remove
+        //  this check and ensure that the following code handles these rules properly (i.e. most of the time the
+        //  bin replenishment only needs internal location, min quantity, reorder quantity, max quantity). Therefore,
+        //  we should create separate command classes to validate the two types of inventory levels.
+        if (params.internalLocation) {
+            log.info "Skipping update to bin replenishment rule ${params}"
+            return
+        }
+
+        // Resolve any objects that require a database lookup
+        params.internalLocation = params.internalLocation ? resolveInternalLocation(facility, params.internalLocation as String) : null
path: grails-app/services/org/pih/warehouse/importer/InventoryLevelImportDataService.groovy
user: login: jmiranda
body: Can you elaborate on the question a bit?
created_at: 2025-03-04T03:37:28Z
author_association: MEMBER
diff_hunk: @@ -10,27 +10,217 @@
 package org.pih.warehouse.importer
 
 import grails.gorm.transactions.Transactional
-import org.pih.warehouse.data.DataService
+import grails.plugins.csv.CSVWriter
+import org.grails.plugins.excelimport.ExpectedPropertyType
+import org.pih.warehouse.core.Location
+import org.pih.warehouse.inventory.InventoryLevel
+import org.pih.warehouse.inventory.InventoryStatus
+import org.pih.warehouse.product.Product
 
 @Transactional
 class InventoryLevelImportDataService implements ImportDataService {
-    DataService dataService
 
     @Override
     void validateData(ImportDataCommand command) {
-        println "validate inventory levels " + command.filename
+        log.info "validate inventory levels " + command.filename
+
         command.data.eachWithIndex { row, index ->
-            if (!dataService.validateInventoryLevel(row)) {
+            if (!validateInventoryLevel(row)) {
                 command.errors.reject("Row ${index + 1}: Failed validation")
             }
         }
     }
 
     @Override
     void importData(ImportDataCommand command) {
-        println "Import inventory levels " + command.location + " filename " + command.filename
-        command.data.eachWithIndex { row, index ->
-            dataService.importInventoryLevel(command.location, row, index)
+        command.data.eachWithIndex { Map params, Integer index ->
+            importInventoryLevel(params, index)
+        }
+    }
+
+    def validateInventoryLevel(Map params) {
+        params.each { key, value ->
+            def expectedType = InventoryLevelExcelImporter.propertyMap.get(key).expectedType
+            switch (expectedType) {
+                case ExpectedPropertyType.IntType:
+                    assert !value || value instanceof Number || value instanceof Boolean, "Value [${value}] for column [${key}] must be a Number or Boolean but was ${value?.class?.name} (" + row + ")."
+                    break
+                case ExpectedPropertyType.DateType:
+                    assert !value || value instanceof Date, "Value [${value}] for column [${key}] must be a Date but was ${value?.class?.name} (" + params + ")."
+                    break
+                default:
+                    break
+            }
+        }
+        return true
+    }
+
+    /**
+     * Import a single row from the XLS file
+     *
+     * @param location
+     * @param fileName
+     * @return
+     */
+    def importInventoryLevel(Map params, Integer index) {
+        log.info "Import inventory levels(${index}): " + params
+
+        def product = Product.findByProductCode(params.productCode as String)
+        if (!product) {
+            throw new IllegalArgumentException("Product with product code ${params.productCode} was not found")
+        }
+
+        // Validate that the facility exists
+        Location facility = Location.findByName(params.inventory as String)
+        if (!facility || !facility.inventory) {
+            throw new IllegalArgumentException("Facility with name ${params.inventory} was not found or is not valid")
+        }
+
+        // TODO Any record with a non-empty internal location is a bin replenishment rule rather than an facility-based
+        //  inventory level rule. In order to protect the bin replenishment rules from being overwritten with this
+        //  data import feature, we're going to skip these records for now. The best way to deal with these will be to
+        //  create a separate importer/exporter to handle the bin replenishment rules separately. Or we can remove
+        //  this check and ensure that the following code handles these rules properly (i.e. most of the time the
+        //  bin replenishment only needs internal location, min quantity, reorder quantity, max quantity). Therefore,
+        //  we should create separate command classes to validate the two types of inventory levels.
+        if (params.internalLocation) {
+            log.info "Skipping update to bin replenishment rule ${params}"
+            return
+        }
+
+        // Resolve any objects that require a database lookup
+        params.internalLocation = params.internalLocation ? resolveInternalLocation(facility, params.internalLocation as String) : null
path: grails-app/services/org/pih/warehouse/importer/InventoryLevelImportDataService.groovy
user: login: awalkowiak
body: Right above you have:
```
if (params.internalLocation) {
    log.info "Skipping update to bin replenishment rule ${params}"
    return
}
```
And wrote that we are skipping it for now, so this line here is not needed (unless you want to keep it for future but then perhaps is worth giving a comment here too, because someone else might be confused later).
created_at: 2025-03-04T08:21:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10,27 +10,217 @@
 package org.pih.warehouse.importer
 
 import grails.gorm.transactions.Transactional
-import org.pih.warehouse.data.DataService
+import grails.plugins.csv.CSVWriter
+import org.grails.plugins.excelimport.ExpectedPropertyType
+import org.pih.warehouse.core.Location
+import org.pih.warehouse.inventory.InventoryLevel
+import org.pih.warehouse.inventory.InventoryStatus
+import org.pih.warehouse.product.Product
 
 @Transactional
 class InventoryLevelImportDataService implements ImportDataService {
-    DataService dataService
 
     @Override
     void validateData(ImportDataCommand command) {
-        println "validate inventory levels " + command.filename
+        log.info "validate inventory levels " + command.filename
+
         command.data.eachWithIndex { row, index ->
-            if (!dataService.validateInventoryLevel(row)) {
+            if (!validateInventoryLevel(row)) {
                 command.errors.reject("Row ${index + 1}: Failed validation")
             }
         }
     }
 
     @Override
     void importData(ImportDataCommand command) {
-        println "Import inventory levels " + command.location + " filename " + command.filename
-        command.data.eachWithIndex { row, index ->
-            dataService.importInventoryLevel(command.location, row, index)
+        command.data.eachWithIndex { Map params, Integer index ->
+            importInventoryLevel(params, index)
+        }
+    }
+
+    def validateInventoryLevel(Map params) {
+        params.each { key, value ->
+            def expectedType = InventoryLevelExcelImporter.propertyMap.get(key).expectedType
+            switch (expectedType) {
+                case ExpectedPropertyType.IntType:
+                    assert !value || value instanceof Number || value instanceof Boolean, "Value [${value}] for column [${key}] must be a Number or Boolean but was ${value?.class?.name} (" + row + ")."
+                    break
+                case ExpectedPropertyType.DateType:
+                    assert !value || value instanceof Date, "Value [${value}] for column [${key}] must be a Date but was ${value?.class?.name} (" + params + ")."
+                    break
+                default:
+                    break
+            }
+        }
+        return true
+    }
+
+    /**
+     * Import a single row from the XLS file
+     *
+     * @param location
+     * @param fileName
+     * @return
+     */
+    def importInventoryLevel(Map params, Integer index) {
+        log.info "Import inventory levels(${index}): " + params
+
+        def product = Product.findByProductCode(params.productCode as String)
path: grails-app/services/org/pih/warehouse/importer/InventoryLevelImportDataService.groovy
user: login: alannadolny
body: > this was existing code

That's my bad, I didn't notice that
created_at: 2025-03-04T10:46:20Z
author_association: COLLABORATOR
diff_hunk: @@ -10,27 +10,217 @@
 package org.pih.warehouse.importer
 
 import grails.gorm.transactions.Transactional
-import org.pih.warehouse.data.DataService
+import grails.plugins.csv.CSVWriter
+import org.grails.plugins.excelimport.ExpectedPropertyType
+import org.pih.warehouse.core.Location
+import org.pih.warehouse.inventory.InventoryLevel
+import org.pih.warehouse.inventory.InventoryStatus
+import org.pih.warehouse.product.Product
 
 @Transactional
 class InventoryLevelImportDataService implements ImportDataService {
-    DataService dataService
 
     @Override
     void validateData(ImportDataCommand command) {
-        println "validate inventory levels " + command.filename
+        log.info "validate inventory levels " + command.filename
+
         command.data.eachWithIndex { row, index ->
-            if (!dataService.validateInventoryLevel(row)) {
+            if (!validateInventoryLevel(row)) {
                 command.errors.reject("Row ${index + 1}: Failed validation")
             }
         }
     }
 
     @Override
     void importData(ImportDataCommand command) {
-        println "Import inventory levels " + command.location + " filename " + command.filename
-        command.data.eachWithIndex { row, index ->
-            dataService.importInventoryLevel(command.location, row, index)
+        command.data.eachWithIndex { Map params, Integer index ->
+            importInventoryLevel(params, index)
+        }
+    }
+
+    def validateInventoryLevel(Map params) {
+        params.each { key, value ->
+            def expectedType = InventoryLevelExcelImporter.propertyMap.get(key).expectedType
+            switch (expectedType) {
+                case ExpectedPropertyType.IntType:
+                    assert !value || value instanceof Number || value instanceof Boolean, "Value [${value}] for column [${key}] must be a Number or Boolean but was ${value?.class?.name} (" + row + ")."
+                    break
+                case ExpectedPropertyType.DateType:
+                    assert !value || value instanceof Date, "Value [${value}] for column [${key}] must be a Date but was ${value?.class?.name} (" + params + ")."
+                    break
+                default:
+                    break
+            }
+        }
+        return true
+    }
+
+    /**
+     * Import a single row from the XLS file
+     *
+     * @param location
+     * @param fileName
+     * @return
+     */
+    def importInventoryLevel(Map params, Integer index) {
+        log.info "Import inventory levels(${index}): " + params
+
+        def product = Product.findByProductCode(params.productCode as String)
+        if (!product) {
+            throw new IllegalArgumentException("Product with product code ${params.productCode} was not found")
+        }
+
+        // Validate that the facility exists
+        Location facility = Location.findByName(params.inventory as String)
+        if (!facility || !facility.inventory) {
+            throw new IllegalArgumentException("Facility with name ${params.inventory} was not found or is not valid")
+        }
+
+        // TODO Any record with a non-empty internal location is a bin replenishment rule rather than an facility-based
+        //  inventory level rule. In order to protect the bin replenishment rules from being overwritten with this
+        //  data import feature, we're going to skip these records for now. The best way to deal with these will be to
+        //  create a separate importer/exporter to handle the bin replenishment rules separately. Or we can remove
+        //  this check and ensure that the following code handles these rules properly (i.e. most of the time the
+        //  bin replenishment only needs internal location, min quantity, reorder quantity, max quantity). Therefore,
+        //  we should create separate command classes to validate the two types of inventory levels.
+        if (params.internalLocation) {
+            log.info "Skipping update to bin replenishment rule ${params}"
+            return
+        }
+
+        // Resolve any objects that require a database lookup
+        params.internalLocation = params.internalLocation ? resolveInternalLocation(facility, params.internalLocation as String) : null
+        params.preferredBinLocation = params.preferredBinLocation ? resolveInternalLocation(facility, params.preferredBinLocation as String) : null
+        params.replenishmentLocation = params.replenishmentLocation ? resolveInternalLocation(facility, params.replenishmentLocation as String) : null
+
+        // Update the inventory level
+        InventoryLevel inventoryLevel = updateOrCreateInventoryLevel(facility, product, params)
+
+        // Delete the inventory level if the status has been set to inactive,
+        if (inventoryLevel.status == InventoryStatus.INACTIVE) {
+            product.removeFromInventoryLevels(inventoryLevel)
+            inventoryLevel.delete()
+            product.save()
+        } else {
+            // Save inventory level OR we could return the inventory level and save them all at the end
+            product.addToInventoryLevels(inventoryLevel)
+            inventoryLevel.save()
+            product.save()
+        }
+    }
+
+    Location resolveInternalLocation(Location facility, String internalLocationName) {
+        Location internalLocation = facility.getInternalLocation(internalLocationName)
+        if (!internalLocation) {
+            throw new IllegalArgumentException("Unable to locate internal location ${internalLocationName} within facility ${facility?.name}")
+        }
+        return internalLocation
path: grails-app/services/org/pih/warehouse/importer/InventoryLevelImportDataService.groovy
user: login: alannadolny
body: I meant adding 
```
if (internalLocationName == null) {
   return null
}
```
at the beginning of the resolveInternalLocation method, so you don't have to check it while assigning to params.XYZ
created_at: 2025-03-04T10:48:15Z
author_association: COLLABORATOR
diff_hunk: @@ -10,27 +10,217 @@
 package org.pih.warehouse.importer
 
 import grails.gorm.transactions.Transactional
-import org.pih.warehouse.data.DataService
+import grails.plugins.csv.CSVWriter
+import org.grails.plugins.excelimport.ExpectedPropertyType
+import org.pih.warehouse.core.Location
+import org.pih.warehouse.inventory.InventoryLevel
+import org.pih.warehouse.inventory.InventoryStatus
+import org.pih.warehouse.product.Product
 
 @Transactional
 class InventoryLevelImportDataService implements ImportDataService {
-    DataService dataService
 
     @Override
     void validateData(ImportDataCommand command) {
-        println "validate inventory levels " + command.filename
+        log.info "validate inventory levels " + command.filename
+
         command.data.eachWithIndex { row, index ->
-            if (!dataService.validateInventoryLevel(row)) {
+            if (!validateInventoryLevel(row)) {
                 command.errors.reject("Row ${index + 1}: Failed validation")
             }
         }
     }
 
     @Override
     void importData(ImportDataCommand command) {
-        println "Import inventory levels " + command.location + " filename " + command.filename
-        command.data.eachWithIndex { row, index ->
-            dataService.importInventoryLevel(command.location, row, index)
+        command.data.eachWithIndex { Map params, Integer index ->
+            importInventoryLevel(params, index)
+        }
+    }
+
+    def validateInventoryLevel(Map params) {
+        params.each { key, value ->
+            def expectedType = InventoryLevelExcelImporter.propertyMap.get(key).expectedType
+            switch (expectedType) {
+                case ExpectedPropertyType.IntType:
+                    assert !value || value instanceof Number || value instanceof Boolean, "Value [${value}] for column [${key}] must be a Number or Boolean but was ${value?.class?.name} (" + row + ")."
+                    break
+                case ExpectedPropertyType.DateType:
+                    assert !value || value instanceof Date, "Value [${value}] for column [${key}] must be a Date but was ${value?.class?.name} (" + params + ")."
+                    break
+                default:
+                    break
+            }
+        }
+        return true
+    }
+
+    /**
+     * Import a single row from the XLS file
+     *
+     * @param location
+     * @param fileName
+     * @return
+     */
+    def importInventoryLevel(Map params, Integer index) {
+        log.info "Import inventory levels(${index}): " + params
+
+        def product = Product.findByProductCode(params.productCode as String)
path: grails-app/services/org/pih/warehouse/importer/InventoryLevelImportDataService.groovy
user: login: jmiranda
body: No worries, you can/should still call it out. It's probably best to "boy scout" these kinds of refactorings if time permits. 
created_at: 2025-03-04T17:04:59Z
author_association: MEMBER
diff_hunk: @@ -10,27 +10,217 @@
 package org.pih.warehouse.importer
 
 import grails.gorm.transactions.Transactional
-import org.pih.warehouse.data.DataService
+import grails.plugins.csv.CSVWriter
+import org.grails.plugins.excelimport.ExpectedPropertyType
+import org.pih.warehouse.core.Location
+import org.pih.warehouse.inventory.InventoryLevel
+import org.pih.warehouse.inventory.InventoryStatus
+import org.pih.warehouse.product.Product
 
 @Transactional
 class InventoryLevelImportDataService implements ImportDataService {
-    DataService dataService
 
     @Override
     void validateData(ImportDataCommand command) {
-        println "validate inventory levels " + command.filename
+        log.info "validate inventory levels " + command.filename
+
         command.data.eachWithIndex { row, index ->
-            if (!dataService.validateInventoryLevel(row)) {
+            if (!validateInventoryLevel(row)) {
                 command.errors.reject("Row ${index + 1}: Failed validation")
             }
         }
     }
 
     @Override
     void importData(ImportDataCommand command) {
-        println "Import inventory levels " + command.location + " filename " + command.filename
-        command.data.eachWithIndex { row, index ->
-            dataService.importInventoryLevel(command.location, row, index)
+        command.data.eachWithIndex { Map params, Integer index ->
+            importInventoryLevel(params, index)
+        }
+    }
+
+    def validateInventoryLevel(Map params) {
+        params.each { key, value ->
+            def expectedType = InventoryLevelExcelImporter.propertyMap.get(key).expectedType
+            switch (expectedType) {
+                case ExpectedPropertyType.IntType:
+                    assert !value || value instanceof Number || value instanceof Boolean, "Value [${value}] for column [${key}] must be a Number or Boolean but was ${value?.class?.name} (" + row + ")."
+                    break
+                case ExpectedPropertyType.DateType:
+                    assert !value || value instanceof Date, "Value [${value}] for column [${key}] must be a Date but was ${value?.class?.name} (" + params + ")."
+                    break
+                default:
+                    break
+            }
+        }
+        return true
+    }
+
+    /**
+     * Import a single row from the XLS file
+     *
+     * @param location
+     * @param fileName
+     * @return
+     */
+    def importInventoryLevel(Map params, Integer index) {
+        log.info "Import inventory levels(${index}): " + params
+
+        def product = Product.findByProductCode(params.productCode as String)
+        if (!product) {
+            throw new IllegalArgumentException("Product with product code ${params.productCode} was not found")
+        }
+
+        // Validate that the facility exists
+        Location facility = Location.findByName(params.inventory as String)
+        if (!facility || !facility.inventory) {
+            throw new IllegalArgumentException("Facility with name ${params.inventory} was not found or is not valid")
+        }
+
+        // TODO Any record with a non-empty internal location is a bin replenishment rule rather than an facility-based
+        //  inventory level rule. In order to protect the bin replenishment rules from being overwritten with this
+        //  data import feature, we're going to skip these records for now. The best way to deal with these will be to
+        //  create a separate importer/exporter to handle the bin replenishment rules separately. Or we can remove
+        //  this check and ensure that the following code handles these rules properly (i.e. most of the time the
+        //  bin replenishment only needs internal location, min quantity, reorder quantity, max quantity). Therefore,
+        //  we should create separate command classes to validate the two types of inventory levels.
+        if (params.internalLocation) {
+            log.info "Skipping update to bin replenishment rule ${params}"
+            return
+        }
+
+        // Resolve any objects that require a database lookup
+        params.internalLocation = params.internalLocation ? resolveInternalLocation(facility, params.internalLocation as String) : null
path: grails-app/services/org/pih/warehouse/importer/InventoryLevelImportDataService.groovy
user: login: jmiranda
body: Oh good call. I think it's fine to leave it for the future, but a comment would be helpful.
created_at: 2025-03-04T17:08:09Z
author_association: MEMBER
diff_hunk: @@ -10,27 +10,217 @@
 package org.pih.warehouse.importer
 
 import grails.gorm.transactions.Transactional
-import org.pih.warehouse.data.DataService
+import grails.plugins.csv.CSVWriter
+import org.grails.plugins.excelimport.ExpectedPropertyType
+import org.pih.warehouse.core.Location
+import org.pih.warehouse.inventory.InventoryLevel
+import org.pih.warehouse.inventory.InventoryStatus
+import org.pih.warehouse.product.Product
 
 @Transactional
 class InventoryLevelImportDataService implements ImportDataService {
-    DataService dataService
 
     @Override
     void validateData(ImportDataCommand command) {
-        println "validate inventory levels " + command.filename
+        log.info "validate inventory levels " + command.filename
+
         command.data.eachWithIndex { row, index ->
-            if (!dataService.validateInventoryLevel(row)) {
+            if (!validateInventoryLevel(row)) {
                 command.errors.reject("Row ${index + 1}: Failed validation")
             }
         }
     }
 
     @Override
     void importData(ImportDataCommand command) {
-        println "Import inventory levels " + command.location + " filename " + command.filename
-        command.data.eachWithIndex { row, index ->
-            dataService.importInventoryLevel(command.location, row, index)
+        command.data.eachWithIndex { Map params, Integer index ->
+            importInventoryLevel(params, index)
+        }
+    }
+
+    def validateInventoryLevel(Map params) {
+        params.each { key, value ->
+            def expectedType = InventoryLevelExcelImporter.propertyMap.get(key).expectedType
+            switch (expectedType) {
+                case ExpectedPropertyType.IntType:
+                    assert !value || value instanceof Number || value instanceof Boolean, "Value [${value}] for column [${key}] must be a Number or Boolean but was ${value?.class?.name} (" + row + ")."
+                    break
+                case ExpectedPropertyType.DateType:
+                    assert !value || value instanceof Date, "Value [${value}] for column [${key}] must be a Date but was ${value?.class?.name} (" + params + ")."
+                    break
+                default:
+                    break
+            }
+        }
+        return true
+    }
+
+    /**
+     * Import a single row from the XLS file
+     *
+     * @param location
+     * @param fileName
+     * @return
+     */
+    def importInventoryLevel(Map params, Integer index) {
+        log.info "Import inventory levels(${index}): " + params
+
+        def product = Product.findByProductCode(params.productCode as String)
+        if (!product) {
+            throw new IllegalArgumentException("Product with product code ${params.productCode} was not found")
+        }
+
+        // Validate that the facility exists
+        Location facility = Location.findByName(params.inventory as String)
+        if (!facility || !facility.inventory) {
+            throw new IllegalArgumentException("Facility with name ${params.inventory} was not found or is not valid")
+        }
+
+        // TODO Any record with a non-empty internal location is a bin replenishment rule rather than an facility-based
+        //  inventory level rule. In order to protect the bin replenishment rules from being overwritten with this
+        //  data import feature, we're going to skip these records for now. The best way to deal with these will be to
+        //  create a separate importer/exporter to handle the bin replenishment rules separately. Or we can remove
+        //  this check and ensure that the following code handles these rules properly (i.e. most of the time the
+        //  bin replenishment only needs internal location, min quantity, reorder quantity, max quantity). Therefore,
+        //  we should create separate command classes to validate the two types of inventory levels.
+        if (params.internalLocation) {
+            log.info "Skipping update to bin replenishment rule ${params}"
+            return
+        }
+
+        // Resolve any objects that require a database lookup
+        params.internalLocation = params.internalLocation ? resolveInternalLocation(facility, params.internalLocation as String) : null
+        params.preferredBinLocation = params.preferredBinLocation ? resolveInternalLocation(facility, params.preferredBinLocation as String) : null
+        params.replenishmentLocation = params.replenishmentLocation ? resolveInternalLocation(facility, params.replenishmentLocation as String) : null
+
+        // Update the inventory level
+        InventoryLevel inventoryLevel = updateOrCreateInventoryLevel(facility, product, params)
+
+        // Delete the inventory level if the status has been set to inactive,
+        if (inventoryLevel.status == InventoryStatus.INACTIVE) {
+            product.removeFromInventoryLevels(inventoryLevel)
+            inventoryLevel.delete()
+            product.save()
+        } else {
+            // Save inventory level OR we could return the inventory level and save them all at the end
+            product.addToInventoryLevels(inventoryLevel)
+            inventoryLevel.save()
+            product.save()
+        }
+    }
+
+    Location resolveInternalLocation(Location facility, String internalLocationName) {
+        Location internalLocation = facility.getInternalLocation(internalLocationName)
+        if (!internalLocation) {
+            throw new IllegalArgumentException("Unable to locate internal location ${internalLocationName} within facility ${facility?.name}")
+        }
+        return internalLocation
path: grails-app/services/org/pih/warehouse/importer/InventoryLevelImportDataService.groovy
user: login: jmiranda
body: Yeah that's what i was thinking you meant. It feels weird to deal with that in the resolve method since it seems to be the API client's responsibility to decide how it wants to deal with that case. A better approach might be to pass a "default" value to the resolve method, so I'll consider doing that.
created_at: 2025-03-04T17:10:47Z
author_association: MEMBER
diff_hunk: @@ -10,27 +10,217 @@
 package org.pih.warehouse.importer
 
 import grails.gorm.transactions.Transactional
-import org.pih.warehouse.data.DataService
+import grails.plugins.csv.CSVWriter
+import org.grails.plugins.excelimport.ExpectedPropertyType
+import org.pih.warehouse.core.Location
+import org.pih.warehouse.inventory.InventoryLevel
+import org.pih.warehouse.inventory.InventoryStatus
+import org.pih.warehouse.product.Product
 
 @Transactional
 class InventoryLevelImportDataService implements ImportDataService {
-    DataService dataService
 
     @Override
     void validateData(ImportDataCommand command) {
-        println "validate inventory levels " + command.filename
+        log.info "validate inventory levels " + command.filename
+
         command.data.eachWithIndex { row, index ->
-            if (!dataService.validateInventoryLevel(row)) {
+            if (!validateInventoryLevel(row)) {
                 command.errors.reject("Row ${index + 1}: Failed validation")
             }
         }
     }
 
     @Override
     void importData(ImportDataCommand command) {
-        println "Import inventory levels " + command.location + " filename " + command.filename
-        command.data.eachWithIndex { row, index ->
-            dataService.importInventoryLevel(command.location, row, index)
+        command.data.eachWithIndex { Map params, Integer index ->
+            importInventoryLevel(params, index)
+        }
+    }
+
+    def validateInventoryLevel(Map params) {
+        params.each { key, value ->
+            def expectedType = InventoryLevelExcelImporter.propertyMap.get(key).expectedType
+            switch (expectedType) {
+                case ExpectedPropertyType.IntType:
+                    assert !value || value instanceof Number || value instanceof Boolean, "Value [${value}] for column [${key}] must be a Number or Boolean but was ${value?.class?.name} (" + row + ")."
+                    break
+                case ExpectedPropertyType.DateType:
+                    assert !value || value instanceof Date, "Value [${value}] for column [${key}] must be a Date but was ${value?.class?.name} (" + params + ")."
+                    break
+                default:
+                    break
+            }
+        }
+        return true
+    }
+
+    /**
+     * Import a single row from the XLS file
+     *
+     * @param location
+     * @param fileName
+     * @return
+     */
+    def importInventoryLevel(Map params, Integer index) {
+        log.info "Import inventory levels(${index}): " + params
+
+        def product = Product.findByProductCode(params.productCode as String)
+        if (!product) {
+            throw new IllegalArgumentException("Product with product code ${params.productCode} was not found")
+        }
+
+        // Validate that the facility exists
+        Location facility = Location.findByName(params.inventory as String)
+        if (!facility || !facility.inventory) {
+            throw new IllegalArgumentException("Facility with name ${params.inventory} was not found or is not valid")
+        }
+
+        // TODO Any record with a non-empty internal location is a bin replenishment rule rather than an facility-based
+        //  inventory level rule. In order to protect the bin replenishment rules from being overwritten with this
+        //  data import feature, we're going to skip these records for now. The best way to deal with these will be to
+        //  create a separate importer/exporter to handle the bin replenishment rules separately. Or we can remove
+        //  this check and ensure that the following code handles these rules properly (i.e. most of the time the
+        //  bin replenishment only needs internal location, min quantity, reorder quantity, max quantity). Therefore,
+        //  we should create separate command classes to validate the two types of inventory levels.
+        if (params.internalLocation) {
+            log.info "Skipping update to bin replenishment rule ${params}"
+            return
+        }
+
+        // Resolve any objects that require a database lookup
+        params.internalLocation = params.internalLocation ? resolveInternalLocation(facility, params.internalLocation as String) : null
+        params.preferredBinLocation = params.preferredBinLocation ? resolveInternalLocation(facility, params.preferredBinLocation as String) : null
+        params.replenishmentLocation = params.replenishmentLocation ? resolveInternalLocation(facility, params.replenishmentLocation as String) : null
+
+        // Update the inventory level
+        InventoryLevel inventoryLevel = updateOrCreateInventoryLevel(facility, product, params)
+
+        // Delete the inventory level if the status has been set to inactive,
+        if (inventoryLevel.status == InventoryStatus.INACTIVE) {
+            product.removeFromInventoryLevels(inventoryLevel)
+            inventoryLevel.delete()
+            product.save()
+        } else {
+            // Save inventory level OR we could return the inventory level and save them all at the end
+            product.addToInventoryLevels(inventoryLevel)
+            inventoryLevel.save()
+            product.save()
+        }
+    }
+
+    Location resolveInternalLocation(Location facility, String internalLocationName) {
+        Location internalLocation = facility.getInternalLocation(internalLocationName)
+        if (!internalLocation) {
+            throw new IllegalArgumentException("Unable to locate internal location ${internalLocationName} within facility ${facility?.name}")
+        }
+        return internalLocation
path: grails-app/services/org/pih/warehouse/importer/InventoryLevelImportDataService.groovy
user: login: jmiranda
body: Even that feels awkward, so I'm going to leave it as-is unless others feel strongly. 
created_at: 2025-03-04T17:13:24Z
author_association: MEMBER

PR comments:
user: login: codecov[bot]
created_at: 2025-03-01T15:56:18Z
body: ## [Codecov](https://app.codecov.io/gh/openboxes/openboxes/pull/5079?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openboxes) Report
Attention: Patch coverage is `1.57480%` with `250 lines` in your changes missing coverage. Please review.
> Project coverage is 7.96%. Comparing base [(`e5e9f1a`)](https://app.codecov.io/gh/openboxes/openboxes/commit/e5e9f1a82cfb7a0d81303012a9f0acd74b32a4ea?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openboxes) to head [(`b283316`)](https://app.codecov.io/gh/openboxes/openboxes/commit/b28331639c404baaa5f22ebbb34bdbd904b80162?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openboxes).
> Report is 206 commits behind head on develop.

| [Files with missing lines](https://app.codecov.io/gh/openboxes/openboxes/pull/5079?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openboxes) | Patch % | Lines |
|---|---|---|
| [...se/importer/InventoryLevelImportDataService.groovy](https://app.codecov.io/gh/openboxes/openboxes/pull/5079?src=pr&el=tree&filepath=grails-app%2Fservices%2Forg%2Fpih%2Fwarehouse%2Fimporter%2FInventoryLevelImportDataService.groovy&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openboxes#diff-Z3JhaWxzLWFwcC9zZXJ2aWNlcy9vcmcvcGloL3dhcmVob3VzZS9pbXBvcnRlci9JbnZlbnRvcnlMZXZlbEltcG9ydERhdGFTZXJ2aWNlLmdyb292eQ==) | 0.00% | [101 Missing :warning: ](https://app.codecov.io/gh/openboxes/openboxes/pull/5079?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openboxes) |
| [...ih/warehouse/requisition/RequisitionService.groovy](https://app.codecov.io/gh/openboxes/openboxes/pull/5079?src=pr&el=tree&filepath=grails-app%2Fservices%2Forg%2Fpih%2Fwarehouse%2Frequisition%2FRequisitionService.groovy&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openboxes#diff-Z3JhaWxzLWFwcC9zZXJ2aWNlcy9vcmcvcGloL3dhcmVob3VzZS9yZXF1aXNpdGlvbi9SZXF1aXNpdGlvblNlcnZpY2UuZ3Jvb3Z5) | 0.00% | [72 Missing :warning: ](https://app.codecov.io/gh/openboxes/openboxes/pull/5079?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openboxes) |
| [...ers/org/pih/warehouse/batch/BatchController.groovy](https://app.codecov.io/gh/openboxes/openboxes/pull/5079?src=pr&el=tree&filepath=grails-app%2Fcontrollers%2Forg%2Fpih%2Fwarehouse%2Fbatch%2FBatchController.groovy&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openboxes#diff-Z3JhaWxzLWFwcC9jb250cm9sbGVycy9vcmcvcGloL3dhcmVob3VzZS9iYXRjaC9CYXRjaENvbnRyb2xsZXIuZ3Jvb3Z5) | 0.00% | [45 Missing :warning: ](https://app.codecov.io/gh/openboxes/openboxes/pull/5079?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openboxes) |
| [...h/warehouse/api/InventoryLevelApiController.groovy](https://app.codecov.io/gh/openboxes/openboxes/pull/5079?src=pr&el=tree&filepath=grails-app%2Fcontrollers%2Forg%2Fpih%2Fwarehouse%2Fapi%2FInventoryLevelApiController.groovy&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openboxes#diff-Z3JhaWxzLWFwcC9jb250cm9sbGVycy9vcmcvcGloL3dhcmVob3VzZS9hcGkvSW52ZW50b3J5TGV2ZWxBcGlDb250cm9sbGVyLmdyb292eQ==) | 0.00% | [24 Missing :warning: ](https://app.codecov.io/gh/openboxes/openboxes/pull/5079?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openboxes) |
| [...warehouse/requisition/RequisitionController.groovy](https://app.codecov.io/gh/openboxes/openboxes/pull/5079?src=pr&el=tree&filepath=grails-app%2Fcontrollers%2Forg%2Fpih%2Fwarehouse%2Frequisition%2FRequisitionController.groovy&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openboxes#diff-Z3JhaWxzLWFwcC9jb250cm9sbGVycy9vcmcvcGloL3dhcmVob3VzZS9yZXF1aXNpdGlvbi9SZXF1aXNpdGlvbkNvbnRyb2xsZXIuZ3Jvb3Z5) | 0.00% | [3 Missing :warning: ](https://app.codecov.io/gh/openboxes/openboxes/pull/5079?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openboxes) |
| [.../org/pih/warehouse/inventory/InventoryLevel.groovy](https://app.codecov.io/gh/openboxes/openboxes/pull/5079?src=pr&el=tree&filepath=grails-app%2Fdomain%2Forg%2Fpih%2Fwarehouse%2Finventory%2FInventoryLevel.groovy&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openboxes#diff-Z3JhaWxzLWFwcC9kb21haW4vb3JnL3BpaC93YXJlaG91c2UvaW52ZW50b3J5L0ludmVudG9yeUxldmVsLmdyb292eQ==) | 25.00% | [3 Missing :warning: ](https://app.codecov.io/gh/openboxes/openboxes/pull/5079?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openboxes) |
| [...arehouse/inventory/InventoryLevelController.groovy](https://app.codecov.io/gh/openboxes/openboxes/pull/5079?src=pr&el=tree&filepath=grails-app%2Fcontrollers%2Forg%2Fpih%2Fwarehouse%2Finventory%2FInventoryLevelController.groovy&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openboxes#diff-Z3JhaWxzLWFwcC9jb250cm9sbGVycy9vcmcvcGloL3dhcmVob3VzZS9pbnZlbnRvcnkvSW52ZW50b3J5TGV2ZWxDb250cm9sbGVyLmdyb292eQ==) | 0.00% | [2 Missing :warning: ](https://app.codecov.io/gh/openboxes/openboxes/pull/5079?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openboxes) |

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff              @@
##             develop   #5079      +/-   ##
============================================
- Coverage       7.98%   7.96%   -0.02%     
+ Complexity       910     907       -3     
============================================
  Files            634     635       +1     
  Lines          43050   42987      -63     
  Branches       10419   10449      +30     
============================================
- Hits            3436    3423      -13     
+ Misses         39091   39042      -49     
+ Partials         523     522       -1     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/openboxes/openboxes/pull/5079?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openboxes).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openboxes).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: jmiranda
created_at: 2025-03-04T03:29:57Z
body: > Is there any benefit of using defs everywhere instead of defining a type?

I'm not sure exactly which "def" you were referring to but I copied existing code (written under Grails 1.3.x) where "def" was more commonly used because using a type sometimes caused problems with hot-reloading and other times with classloading. Going forward, we should definitely try to use def so if you see specific examples definitely point them out.
author_association: MEMBER