OBLS-781 Bulk APIs for products and inventory levels#5976
Conversation
| def persistenceInterceptor | ||
|
|
||
| def triggerRefreshInventorySnapshot(String locationId, List<String> productIds, Boolean forceRefresh) { | ||
| Date runAt = new Date(System.currentTimeMillis() + 5000) |
There was a problem hiding this comment.
5secs for safety, should be moved to config instead of hardcoding
There was a problem hiding this comment.
Why can't we reuse the refresh solution we're using in another PR so we don't need to add the delay?
There was a problem hiding this comment.
hmm, what do you mean here?
There was a problem hiding this comment.
There was a problem hiding this comment.
I pushed a new commit removing job delay
druchniewicz
left a comment
There was a problem hiding this comment.
comments to myself
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## feature/obaf-integration #5976 +/- ##
==============================================================
- Coverage 10.43% 10.39% -0.04%
+ Complexity 1711 1693 -18
==============================================================
Files 901 909 +8
Lines 51619 51939 +320
Branches 12239 12310 +71
==============================================================
+ Hits 5384 5400 +16
- Misses 45379 45679 +300
- Partials 856 860 +4 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
jmiranda
left a comment
There was a problem hiding this comment.
I'm going to post this as a first pass to get us moving. I might have some more feedback in a bit.
| render([data: toJson(InventoryLevel.get(result.inventoryLevelId))] as JSON) | ||
| } | ||
|
|
||
| private Map toJson(InventoryLevel inventoryLevel) { |
There was a problem hiding this comment.
Move this method to the InventoryLevel domain and register a marshaller through Bootstrap.groovy.
| super(source) | ||
| this.product = source | ||
| this.disableRefresh = disableRefresh | ||
| this.disableRefresh = source.disableRefresh |
| status : 'error', | ||
| errorMessage: 'Validation failed', | ||
| errors : inventoryLevel.errors.allErrors.collect { | ||
| [field: it.field, code: it.code, message: it.defaultMessage ?: it.code] |
There was a problem hiding this comment.
allErrors might contain FieldErrors and ObjectErrors, so we need to be careful here.
You have a few options
- Assume allErrors is a collection of ObjectError since FieldError extends ObjectError
- Use
it instanceof FieldError ? it.field : nullto guard against a MissingPropertyException - Use
inventoryLevel.errors.fieldErrors.collect { FieldError error -> ...but that might drop errors we want to display, so maybe not a good idea.
| Product product = Product.findByIdOrProductCode(json.id, json.productCode) ?: new Product() | ||
| boolean isNew = !product.id | ||
|
|
||
| bindProductData(product, json) |
There was a problem hiding this comment.
Why aren't we doing a similar thing for product that we do with inventory level.
There was a problem hiding this comment.
I added bindProductData(product, json) and bindInventoryLevelData(inventoryLevel, json) methods because we moved logic from controllers to the services. Apart from that we have custom logic for binding e.g. want to pass productId or productCode in the payload or e.g. for preferredBinLocation we want to pass id or location number so we have to find correct values first. Default grails binding works in controllers and for objects it binds always by "id".
| } | ||
|
|
||
| // @NotTransactional to avoid two open sessions with the per-item withNewTransaction | ||
| @NotTransactional |
There was a problem hiding this comment.
This needs to be avoid sometimes two open sessions. We have one transaction on ProductService and in upsert logic we have Product.withNewTransaction { ... } what opens a new session. During saving two sessions sometimes touch the same entity sub-resources and exception is thrown: org.pih.warehouse.product.ProductService: upsert: illegally attempted to associate a proxy with two open Sessions; nested exception is org.hibernate.HibernateException: illegally attempted to associate a proxy with two open Sessions
There was a problem hiding this comment.
Ok, this probably needs a bit more discovery to find the best solution. I understand the two session issue, but assume that we're also doing this because a method in a service calling another method in the same service will bypass the Spring transaction proxy.
But we can deal with that later. Perhaps a separate BulkUpsertService dedicated to the wrapper bulkUpsert but leave the upsert in the domain-specific service (e.g. ProductService) where it belongs. However, that's side-stepping the problem as well.
| try { | ||
| InventoryLevel.withNewTransaction { status -> | ||
| InventoryLevel inventoryLevel = | ||
| (json.identifier ? InventoryLevel.findByIdentifier(json.identifier) : null) ?: new InventoryLevel() |
There was a problem hiding this comment.
The identifier is currently not unique (yet) so this could hypothentically return multiple records. I'm not exactly sure how we want to handle that but just wanted to call it out.
There was a problem hiding this comment.
We could actually add a unique constraint. I don't think there's a problem with that especially given the fact that you can have multiple records with a null identifier without breaking the unique constraint.
|
|
||
| body.identifier = params.identifier | ||
|
|
||
| Map result = inventoryLevelService.bulkUpsert(facility, [body], false).first() |
There was a problem hiding this comment.
It feels like this should just return the list of objects saved or validated with errors. Or a DTO that wraps these and can be rendered to json.
| } | ||
|
|
||
| boolean deferRefresh = params.boolean('deferRefresh', false) | ||
| def body = request.JSON |
There was a problem hiding this comment.
Nitpick: let's call this variable json or jsonObject. body is too generic.
| throw new IllegalArgumentException("Expected a JSON array of inventory levels") | ||
| } | ||
|
|
||
| List<Map> results = inventoryLevelService.bulkUpsert(facility, (JSONArray) body, deferRefresh) |
There was a problem hiding this comment.
I don't love the return being a list of maps with an arbitrary shape and then the magic strings all over ("ok", "error")
Can we either return the saved and/or validated (with errors) instances and then render those. Or better yet, create an UpsertResult DTO
enum UpsertStatus { OK, ERROR }
enum UpsertAction { CREATED, UPDATED }
class UpsertResult {
int index
UpsertStatus status
UpsertAction action // note: this will be null on error
String entityId // productId / inventoryLevelId, etc
String errorMessage
List<Map> errors
}
There was a problem hiding this comment.
and then the methods and return types will look like this
UpsertResult upsert(Map json, boolean deferRefresh) // upsert one row
List<UpsertResult> bulkUpsert(List<Map> jsonArray, boolean deferRefresh) {
items.withIndex().collect { Map json, int i -> upsert(json, deferRefresh).withIndex(i) }
}
and we can add a marshaller for the UpsertResult like we do with other objects (add a toJson method and register it in BootStrap.groovy)
There was a problem hiding this comment.
Pull request overview
This PR adds bulk upsert capabilities for Products and Inventory Levels via new API endpoints, and introduces a disableRefresh flag on Product to support deferring refresh side-effects during bulk operations.
Changes:
- Add product bulk upsert service/controller flow and route
PUT /api/products. - Add inventory level bulk + single upsert endpoints under facilities, plus a new
InventoryLevelServiceto implement the bulk upsert behavior. - Add helper methods to refresh product availability and inventory snapshots by product ID list, and wire inventory snapshot refresh job scheduling.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/main/groovy/org/pih/warehouse/inventory/InventorySnapshotEvent.groovy | Fixes event constructor to carry disableRefresh from Product source. |
| grails-app/services/org/pih/warehouse/product/ProductService.groovy | Adds bulk upsert service logic and map-based data binding helper. |
| grails-app/services/org/pih/warehouse/inventory/ProductAvailabilityService.groovy | Adds helper to update product availability by product ID list. |
| grails-app/services/org/pih/warehouse/inventory/InventorySnapshotService.groovy | Adds inventory snapshot refresh job triggering and update-by-product-ID-list helper. |
| grails-app/services/org/pih/warehouse/inventory/InventoryLevelService.groovy | New service implementing inventory level bulk upsert + association resolution/binding. |
| grails-app/services/org/pih/warehouse/core/LocationService.groovy | Switches location creation persistence to location.save(flush:true). |
| grails-app/services/org/pih/warehouse/core/LocationDataService.groovy | Fixes GORM data service domain type to Location. |
| grails-app/domain/org/pih/warehouse/product/Product.groovy | Adds transient disableRefresh flag to Product. |
| grails-app/controllers/org/pih/warehouse/UrlMappings.groovy | Adds PUT mappings for product upsert and inventory level bulk/single upsert routes. |
| grails-app/controllers/org/pih/warehouse/api/ProductApiController.groovy | Adds upsert() supporting single or array payloads and bulk response metadata. |
| grails-app/controllers/org/pih/warehouse/api/InventoryLevelApiController.groovy | Adds bulk upsert and single upsert endpoints + response formatting/refresh triggering. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| private Product bindProductData(Product product, Map source) { | ||
| DataBindingUtils.bindObjectToInstance(product, new SimpleMapDataBindingSource(source)) | ||
| return product | ||
| } |
| InventoryLevel inventoryLevel = | ||
| (json.identifier ? InventoryLevel.findByIdentifier(json.identifier) : null) ?: new InventoryLevel() | ||
| boolean isNew = !inventoryLevel.id |
| return | ||
| } | ||
|
|
||
| inventoryLevel.save() |
|
@jmiranda changes have been applied. I also replied to some of your comments |
| if (deferRefresh && productIds) { | ||
| productAvailabilityService.updateProductAvailability(productIds) | ||
| inventorySnapshotService.updateInventorySnapshots(productIds) | ||
| } |
There was a problem hiding this comment.
We need to keep an eye on the performance of this when the batch size is in the 100s or 1000s. I might also recommend making this asynchronous.
There was a problem hiding this comment.
I think it should not be a problem when split it to batches. We'll see
There was a problem hiding this comment.
As I mentioned above, I'm realizing this is just doing an update on these tables if the product code is changing. Therefore it's not actually trigger a recompute of product availability. I think the same two methods could be used above when upserting the inventory levels.
| <indexExists indexName="uc_inventory_level_identifier"/> | ||
| </not> | ||
| </preConditions> | ||
| <addUniqueConstraint tableName="inventory_level" |
There was a problem hiding this comment.
We need to make sure this gets tested on existing databases as it could fail.
There was a problem hiding this comment.
right, I removed inventory levels with duplicated identifiers
There was a problem hiding this comment.
@druchniewicz, cleaning it in one of the dbs does not handle the issue, since others might still have duplicates in their dbs. For example, I have, so this still would fail for me after merging. Here is an example how we handle it in core for a similar issue: https://github.com/openboxes/openboxes/pull/5867/changes#diff-c2f5fe6aed319d3887faa5de363feb7794f117d2056575338907cb3b31dce95aR6-R37
| // the refresh jobs don't need to be scheduled with a delay | ||
| @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) | ||
| void onInventoryLevelUpdated(InventoryLevelUpdatedEvent event) { | ||
| productAvailabilityService.triggerRefreshProductAvailability(event.facilityId, [event.productId], event.forceRefresh) |
There was a problem hiding this comment.
Can we add some logging to make sure this gets fired when and how we're expecting.
| Product product = Product.findByIdOrProductCode(json.id, json.productCode) ?: new Product() | ||
| boolean isNew = !product.id | ||
|
|
||
| bindProductData(product, json) |
There was a problem hiding this comment.
Note: This is where I think the value converter would come in handy. Makes it so we don't need the findByIdOrProductCode above. Nothing we need to do now, but we might want to come back to it later.
|
@jmiranda PR updated |
| List<String> productIds = results.findAll { it.status == UpsertStatus.OK && it.productId }*.productId.unique() | ||
| if (deferRefresh && productIds) { | ||
| productAvailabilityService.triggerRefreshProductAvailability(facility.id, productIds, true) | ||
| inventorySnapshotService.triggerRefreshInventorySnapshot(facility.id, productIds, true) |
There was a problem hiding this comment.
This block should eventually be converted to just publishing an event. And then we can move the refresh code to an event service.
There was a problem hiding this comment.
I'm just realizing that this isn't what I thought it was. We actually have no reason to recompute the product availability here. The only thing that might be happeing is an update to the product code that needs to be reflected in the product availability and inventory snapshot table. So this is not a big deal at all, could be event'd and async'd but does not need to be.
cc @awalkowiak We probably need to make a distinction between updating and refreshing / recomputing product availability. And we actually probably could try to narrow the "event" to only care about the products that have changed their product code. Not sure how we'd do that (some type of hibernate interceptor, i assume) so not worth the effort at the moment.
There was a problem hiding this comment.
Lastly, this does not even need to trigger the refresh, it could just do the updates inline but it's not necessary to make that change as long as this works fine. We should try to test this out by changing a few product codes via bulk upsert (assumes we include the product IDs in the upsert JSON).
| if (deferRefresh && productIds) { | ||
| productAvailabilityService.updateProductAvailability(productIds) | ||
| inventorySnapshotService.updateInventorySnapshots(productIds) | ||
| } |
There was a problem hiding this comment.
As I mentioned above, I'm realizing this is just doing an update on these tables if the product code is changing. Therefore it's not actually trigger a recompute of product availability. I think the same two methods could be used above when upserting the inventory levels.
| response.status = HttpStatus.BAD_REQUEST.value() | ||
| } | ||
|
|
||
| render([data: result] as JSON) |
There was a problem hiding this comment.
I might be wrong about letting the PUT /api/products be used for resource and resource collections. The same endpoint responds with different response shapes depending on the input and that seems like it might eventually be confusing. We're going to leave it for now, but @druchniewicz let me know if you encounter any developer experience (DX) issues related to that.
There was a problem hiding this comment.
@jmiranda yeah indeed it may be a bit confusing, but we need to somehow connect it with existing mappings to not break anything.
In my ideal world if there was no API yet and I was writing it from scratch, I would do it like this:
API for Products:
- Get list of products ->
GET /api/products (no body) - Get single product ->
GET /api/products/{idOrCode} (no body) - Create single product ->
POST /api/products (single product object) - Upsert/update single product ->
PUT /api/products/{id} (single product object) - Bulk upsert ->
POST /api/products/bulk (array of objects) - Delete product ->
DELETE /api/products/{id}
API for Inventory Levels:
- Get list of inventory levels ->
GET /api/facilities/{facilityId}/inventory-levels (no body) - Get single inventory level ->
GET /api/facilities/{facilityId}/inventory-levels/{identifier} (no body) - Create single inventory level ->
POST /api/facilities/{facilityId}/inventory-levels (single inv level object) - Upsert/update single inventory level -
PUT /api/facilities/{facilityId}/inventory-levels/{identifier} (single inv level object) - Bulk upsert ->
POST /api/facilities/{facilityId}/inventory-levels/bulk (array of objects) - Delete inventory level -
DELETE /api/facilities/{facilityId}/inventory-levels/{identifier}
| <indexExists indexName="uc_inventory_level_identifier"/> | ||
| </not> | ||
| </preConditions> | ||
| <addUniqueConstraint tableName="inventory_level" |
There was a problem hiding this comment.
@druchniewicz, cleaning it in one of the dbs does not handle the issue, since others might still have duplicates in their dbs. For example, I have, so this still would fail for me after merging. Here is an example how we handle it in core for a similar issue: https://github.com/openboxes/openboxes/pull/5867/changes#diff-c2f5fe6aed319d3887faa5de363feb7794f117d2056575338907cb3b31dce95aR6-R37
|
@awalkowiak PR updated with new db migration to resolve duplicate identifiers |
No description provided.