Skip to content

OBLS-781 Bulk APIs for products and inventory levels#5976

Merged
awalkowiak merged 5 commits into
feature/obaf-integrationfrom
OBLS-781
Jun 24, 2026
Merged

OBLS-781 Bulk APIs for products and inventory levels#5976
awalkowiak merged 5 commits into
feature/obaf-integrationfrom
OBLS-781

Conversation

@druchniewicz

Copy link
Copy Markdown
Collaborator

No description provided.

@github-actions github-actions Bot added domain: backend Changes or discussions relating to the backend server stakeholder: vvg Issues or topics relating to VVG (velocity vehicle group) labels Jun 12, 2026
Comment thread grails-app/services/org/pih/warehouse/core/LocationService.groovy
def persistenceInterceptor

def triggerRefreshInventorySnapshot(String locationId, List<String> productIds, Boolean forceRefresh) {
Date runAt = new Date(System.currentTimeMillis() + 5000)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5secs for safety, should be moved to config instead of hardcoding

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why can't we reuse the refresh solution we're using in another PR so we don't need to add the delay?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm, what do you mean here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pushed a new commit removing job delay

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@druchniewicz Yes, that's what i had in mind.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

corrected

@druchniewicz druchniewicz left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

comments to myself

@codecov

codecov Bot commented Jun 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 6.01093% with 172 lines in your changes missing coverage. Please review.
✅ Project coverage is 10.39%. Comparing base (5f61d78) to head (528fd1a).
⚠️ Report is 13 commits behind head on feature/obaf-integration.

Files with missing lines Patch % Lines
...h/warehouse/inventory/InventoryLevelService.groovy 2.17% 45 Missing ⚠️
...h/warehouse/api/InventoryLevelApiController.groovy 0.00% 31 Missing ⚠️
...es/org/pih/warehouse/product/ProductService.groovy 3.33% 29 Missing ⚠️
.../org/pih/warehouse/api/ProductApiController.groovy 0.00% 21 Missing ⚠️
...n/groovy/org/pih/warehouse/api/UpsertResult.groovy 0.00% 14 Missing ⚠️
.../org/pih/warehouse/inventory/InventoryLevel.groovy 10.00% 9 Missing ⚠️
...arehouse/inventory/InventorySnapshotService.groovy 0.00% 7 Missing ⚠️
...inventory/InventoryLevelUpdatedEventService.groovy 0.00% 5 Missing ⚠️
...ehouse/inventory/InventoryLevelUpdatedEvent.groovy 0.00% 5 Missing ⚠️
...ehouse/inventory/ProductAvailabilityService.groovy 0.00% 3 Missing ⚠️
... and 2 more
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jmiranda jmiranda left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move this method to the InventoryLevel domain and register a marshaller through Bootstrap.groovy.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

super(source)
this.product = source
this.disableRefresh = disableRefresh
this.disableRefresh = source.disableRefresh

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch

status : 'error',
errorMessage: 'Validation failed',
errors : inventoryLevel.errors.allErrors.collect {
[field: it.field, code: it.code, message: it.defaultMessage ?: it.code]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

allErrors might contain FieldErrors and ObjectErrors, so we need to be careful here.

You have a few options

  1. Assume allErrors is a collection of ObjectError since FieldError extends ObjectError
  2. Use it instanceof FieldError ? it.field : null to guard against a MissingPropertyException
  3. Use inventoryLevel.errors.fieldErrors.collect { FieldError error -> ... but that might drop errors we want to display, so maybe not a good idea.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Product product = Product.findByIdOrProductCode(json.id, json.productCode) ?: new Product()
boolean isNew = !product.id

bindProductData(product, json)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why aren't we doing a similar thing for product that we do with inventory level.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you elaborate on this?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@jmiranda jmiranda Jun 22, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done


body.identifier = params.identifier

Map result = inventoryLevelService.bulkUpsert(facility, [body], false).first()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

}

boolean deferRefresh = params.boolean('deferRefresh', false)
def body = request.JSON

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: let's call this variable json or jsonObject. body is too generic.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

throw new IllegalArgumentException("Expected a JSON array of inventory levels")
}

List<Map> results = inventoryLevelService.bulkUpsert(facility, (JSONArray) body, deferRefresh)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 InventoryLevelService to 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.

Comment on lines +1268 to +1271
private Product bindProductData(Product product, Map source) {
DataBindingUtils.bindObjectToInstance(product, new SimpleMapDataBindingSource(source))
return product
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Comment on lines +39 to +41
InventoryLevel inventoryLevel =
(json.identifier ? InventoryLevel.findByIdentifier(json.identifier) : null) ?: new InventoryLevel()
boolean isNew = !inventoryLevel.id

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

return
}

inventoryLevel.save()
Comment thread grails-app/controllers/org/pih/warehouse/UrlMappings.groovy Outdated
@github-actions github-actions Bot added flag: schema change Hilights a pull request that contains a change to the database schema flag: config change Hilights a pull request that contains a change to the app config labels Jun 17, 2026
@druchniewicz
druchniewicz requested a review from jmiranda June 17, 2026 17:06
Comment thread grails-app/controllers/org/pih/warehouse/UrlMappings.groovy
@druchniewicz

druchniewicz commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator Author

@jmiranda changes have been applied. I also replied to some of your comments
One requested change is still pending, I have question what do you mean here - #5976 (comment)

@druchniewicz
druchniewicz requested a review from jmiranda June 20, 2026 12:23
if (deferRefresh && productIds) {
productAvailabilityService.updateProductAvailability(productIds)
inventorySnapshotService.updateInventorySnapshots(productIds)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it should not be a problem when split it to batches. We'll see

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to make sure this gets tested on existing databases as it could fail.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right, I removed inventory levels with duplicated identifiers

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add some logging to make sure this gets fired when and how we're expecting.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Product product = Product.findByIdOrProductCode(json.id, json.productCode) ?: new Product()
boolean isNew = !product.id

bindProductData(product, json)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@druchniewicz

Copy link
Copy Markdown
Collaborator Author

@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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block should eventually be converted to just publishing an event. And then we can move the refresh code to an event service.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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:

  1. Get list of products -> GET /api/products (no body)
  2. Get single product -> GET /api/products/{idOrCode} (no body)
  3. Create single product -> POST /api/products (single product object)
  4. Upsert/update single product -> PUT /api/products/{id} (single product object)
  5. Bulk upsert -> POST /api/products/bulk (array of objects)
  6. Delete product -> DELETE /api/products/{id}

API for Inventory Levels:

  1. Get list of inventory levels -> GET /api/facilities/{facilityId}/inventory-levels (no body)
  2. Get single inventory level -> GET /api/facilities/{facilityId}/inventory-levels/{identifier} (no body)
  3. Create single inventory level -> POST /api/facilities/{facilityId}/inventory-levels (single inv level object)
  4. Upsert/update single inventory level - PUT /api/facilities/{facilityId}/inventory-levels/{identifier} (single inv level object)
  5. Bulk upsert -> POST /api/facilities/{facilityId}/inventory-levels/bulk (array of objects)
  6. Delete inventory level - DELETE /api/facilities/{facilityId}/inventory-levels/{identifier}

<indexExists indexName="uc_inventory_level_identifier"/>
</not>
</preConditions>
<addUniqueConstraint tableName="inventory_level"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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

@druchniewicz

Copy link
Copy Markdown
Collaborator Author

@awalkowiak PR updated with new db migration to resolve duplicate identifiers

@awalkowiak
awalkowiak merged commit af0e638 into feature/obaf-integration Jun 24, 2026
7 checks passed
@awalkowiak
awalkowiak deleted the OBLS-781 branch June 24, 2026 12:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

domain: backend Changes or discussions relating to the backend server flag: config change Hilights a pull request that contains a change to the app config flag: schema change Hilights a pull request that contains a change to the database schema stakeholder: vvg Issues or topics relating to VVG (velocity vehicle group)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants