Skip to content

OBPIH-7542 Create backend for new reorder report#5568

Merged
alannadolny merged 5 commits intodevelopfrom
ft/OBPIH-7542
Oct 28, 2025
Merged

OBPIH-7542 Create backend for new reorder report#5568
alannadolny merged 5 commits intodevelopfrom
ft/OBPIH-7542

Conversation

@kchelstowski
Copy link
Collaborator

✨ Description of Change

Link to GitHub issue or Jira ticket:

Description:


📷 Screenshots & Recordings (optional)

@kchelstowski kchelstowski self-assigned this Oct 24, 2025
@github-actions github-actions bot added type: feature A new piece of functionality for the app domain: backend Changes or discussions relating to the backend server domain: l10n Changes or discussions relating to localization & Internationalization labels Oct 24, 2025
if (command.hasErrors()) {
throw new ValidationException("Invalid filters", command.errors)
}
List<ReorderReportItemDto> reorderReport = dashboardService.getReorderReport(command)
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I don't know if DashboardService is the place where this report should be placed, but this is where this report has always been, so I'm happy to discuss it

Copy link
Member

@ewaterman ewaterman Oct 24, 2025

Choose a reason for hiding this comment

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

yeah I'm not a big fan of these huge general purpose controller and service classes. I'd much rather have a specific InventoryReportService, or even ReorderReportService. That way we have better encapsulation of logic and it incentivises building small, re-usable sub-components that we can hook in everywhere

ultimately I'm fine with this as it is though so I leave it up to you

@kchelstowski kchelstowski force-pushed the ft/OBPIH-7542 branch 2 times, most recently from b6ad971 to f4eec5b Compare October 24, 2025 12:40
@codecov
Copy link

codecov bot commented Oct 24, 2025

Codecov Report

❌ Patch coverage is 2.08333% with 141 lines in your changes missing coverage. Please review.
✅ Project coverage is 8.49%. Comparing base (1bb7314) to head (632f8a2).
⚠️ Report is 160 commits behind head on develop.

Files with missing lines Patch % Lines
...ces/org/pih/warehouse/core/DashboardService.groovy 0.00% 79 Missing ⚠️
...ehouse/inventory/ProductAvailabilityService.groovy 0.00% 33 Missing ⚠️
...rg/pih/warehouse/api/InventoryApiController.groovy 0.00% 15 Missing ⚠️
...ehouse/inventory/ReorderReportFilterCommand.groovy 0.00% 10 Missing ⚠️
...rg/pih/warehouse/inventory/ExpirationFilter.groovy 0.00% 2 Missing ⚠️
...ih/warehouse/inventory/ReorderReportItemDto.groovy 0.00% 1 Missing ⚠️
...ory/product/availability/InventoryByProduct.groovy 0.00% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             develop   #5568      +/-   ##
============================================
- Coverage       9.12%   8.49%   -0.64%     
+ Complexity      1170    1111      -59     
============================================
  Files            701     707       +6     
  Lines          45281   45498     +217     
  Branches       10851   10902      +51     
============================================
- Hits            4131    3863     -268     
- Misses         40497   41060     +563     
+ Partials         653     575      -78     

☔ View full report in Codecov by Sentry.
📢 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.

if (command.hasErrors()) {
throw new ValidationException("Invalid filters", command.errors)
}
List<ReorderReportItemDto> reorderReport = dashboardService.getReorderReport(command)
Copy link
Member

@ewaterman ewaterman Oct 24, 2025

Choose a reason for hiding this comment

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

yeah I'm not a big fan of these huge general purpose controller and service classes. I'd much rather have a specific InventoryReportService, or even ReorderReportService. That way we have better encapsulation of logic and it incentivises building small, re-usable sub-components that we can hook in everywhere

ultimately I'm fine with this as it is though so I leave it up to you

action = "importCsv"
}

"/api/inventories/reorderReport" {
Copy link
Member

Choose a reason for hiding this comment

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

Shouldn't we maintain getting location via /facilities/$facilityId in the uri?

}
and {
isNotNull("reorderQuantity")
gt("reorderQuantity", 0)
Copy link
Member

Choose a reason for hiding this comment

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

if you just do gt it includes nulls?? Weird...

]

// If an item satisfies the predicate, call the buildReorderReportItem, otherwise return null so that .findResults filters out the record in the end
return inventoryLevelStatusFilterCondition[command.inventoryLevelStatus](item) ? buildReorderReportItem(item, inventoryLevel) : null
Copy link
Member

Choose a reason for hiding this comment

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

what's the advantage here of building a map of closures vs just having a switch statement based on InventoryLevelStatus? I'm fine with this (though I'd be curious to know the performance difference ie how much of it is pre-compiled) but I don't see much difference compared to:

Boolean x
switch(command.inventoryLevelStatus) {
    case InventoryLevelStatus.IN_STOCK:
        x = ...
    case ...:
        x = ...
}
return x ? build...() : null

Copy link
Member

Choose a reason for hiding this comment

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

also, neat! I didn't know findResults existed

Copy link
Collaborator Author

@kchelstowski kchelstowski Oct 24, 2025

Choose a reason for hiding this comment

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

I actually haven't considered (and haven't thought about) the switch, as after finding out nested closures made me want to use them everywhere 🙈 so I can't say what is more performant, but I'd say the difference would not be even noticeable.


// .findResults is a shortened way of doing .findAll{...}.collect{...}
List<ReorderReportItemDto> reorderReportItems = inventoriesByProduct.findResults { InventoryByProduct item ->
InventoryLevel inventoryLevel = inventoryLevelByProduct[item.product]?.first()
Copy link
Member

Choose a reason for hiding this comment

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

why the call to first()? Are we ever expecting there to be more than one level per product?

If they're one-to-one, then I'd change the above to:

Map<Product, InventoryLevel> inventoryLevelByProduct = inventoryLevels.collectEntries { [it.product, it] }

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I forgot about the .collectEntries, thanks

}

String getReorderReportCsv(List<ReorderReportItemDto> reorderReport) {
ApplicationTagLib g = applicationTagLib
Copy link
Member

Choose a reason for hiding this comment

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

IMO we shouldn't need to invoke taglibs unless we're trying to build html. The way I look at it is g.message is what we use for GSPs.

I recently refactored our l10n logic to create the MessageLocalizer class for this use case (where we want to localize a message within a service for use in JSON or CSV responses).

You can call it with: messageLocalizer.localize("product.label") or messageLocalizer.localize(new LocalizableMessage("product.label", "Product"))

I won't force you to use it though.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

that's awesome, I have forgotten (or haven't even seen 🙈 ) you have created a util for that. I will check it out and eventually do the refactor.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@ewaterman I refactored the code to use the messageLocalizer. I have one suggestion though - it feels like with the current state of the MessageLocalizer class, we don't use the defaultMessage at all.
The same thing with the LocalizableMessage - we can provide it via constructor like you showed, but in the end we just call localize method with the message.code and message.args (defaultMessage is not used at all).

Copy link
Member

@ewaterman ewaterman Oct 27, 2025

Choose a reason for hiding this comment

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

I did that intentionally actually. I saw a few places in the code where we were using a label that no longer existed and so it was always using the default but I couldn't tell because I was always working in english. I figured if we always displayed the label when we it failed to localize (instead of a default) then we'd see the problem right away and could fix it immediately.

However I do realize that there are benefits to having a default, such not totally breaking text when we accidentally remove a label that was being used.

If you don't want to do it here, I can create a separate PR to support defaults, but if you want it in this PR, you can modify MessageLocalizer.localize to add a String defaultMessage param after code (and then removing the If the code does not resolve... part of the docstring

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@ewaterman if it's intentional, then I'm fine with it. Actuallly thanks to that, it made me realize a few existing labels (copied from the existing report) didn't actually exist.
I think the only reason to have the default would be if we could answer if it's even possible for the localizer not to work. If we are 100% sure it would work, I think we should even avoid using default to (as you mentioned) quickly detect not existing labels.

* expiration (we can include/exclude inventory items that expire in 30/90/180/365 days),
* additionalLocations - locations to search (and sum) the stock in (other than current location)
*/
List<InventoryByProduct> getInventoriesByProduct(ReorderReportFilterCommand command) {
Copy link
Member

Choose a reason for hiding this comment

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

I'm not a huge fan of having a method in product availability service that requires a reorder report context object. It needlessly ties the two features together and forces the called service to have knowledge about the service calling it. IMO better would be to either:

  1. spread the command fields out as individual method args or if that's too messy...

  2. create a separate context object that this method accepts that doesn't reference the feature that uses it (and then convert ReorderReportFilterCommand to that object when calling this method).

not a required refactor though if you disagree

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good point. I assume we'd need 4 args to be passed, so if you are fine I will do it this way.
I'm also curious about the 2nd point how you would see it - what exact context object would you see in this place?

Copy link
Member

Choose a reason for hiding this comment

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

yup that's fine. I'd actually prefer that for now.

The "context object" would just be a POJO that wraps the params:

getInventoriesByProduct(ProductAvailabilityFilterContext context)

That way we don't get stuck with a method that has a really big list of params, most of them optional. In this situation though, I'd just list the params like you did.

I try not to overuse context objects because it somewhat masks the params and requires us to init a new object whenever we want to call the method, but it does help in some situations

sum("quantityAvailableToPromise")
groupProperty("product")
}
inList("location", locations)
Copy link
Member

Choose a reason for hiding this comment

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

why inList and not eq? You only have a single location

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

We have at least one location (current location), but you can pass additionalLocations so that you end up with the list.
I thought it would be form oversubstance if I were to do :

if (command.additionalLocations) {
  inList("location", locations)
} else {
  eq("location", AuthService.currentLocation
}

What do you think?

}
inList("location", locations)

if (command.categories || command.tags) {
Copy link
Member

Choose a reason for hiding this comment

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

if you don't add this outer if check does it break if both are empty? I'm not certain but I think you could also do something like this if you prefer:

if (command.categories) {
    inList("product.category", command.categories)
}
if (command.tags) {
    ...
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'm wondering what I was thinking when writing this line:

if (command.categories || command.tags) {

XD

....

I was probably tired and wanted to push the code ASAP, so thanks for catching those weird cases 😆

TL:DR - it's definitely enough to do it like:

  if (command.categories) {
    inList("product.category", command.categories)
}
if (command.tags) {
    ...
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@ewaterman Oh, I remember the reason for doing it. If I were to separate it, I would end up with:

if (categories) {
      product {
          inList("category", categories)
      }
}
if (tags) {
    product {
         tags {
            'in'("id", tags.id)
        }
    }
}

and if both categories and tags were provided, an error would be thrown that product is already joined.
I have usually handled it via usedAliases that I was storing in Set, but I believe for this simple filter, it's better to just check at the top if either one of categories/tags is provided and join product once.

Copy link
Member

Choose a reason for hiding this comment

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

yeah that's fine. Thanks for looking into it. I wonder if this would work though:

if (categories) {
    inList("product.category", categories)
}
if (tags) {
    inList("product.tags", tags)
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It wouldn't. We either need to join the table like I did, or to use createAlias:
Screenshot From 2025-10-27 17-06-55

if (expirationFilter == ExpirationFilter.REMOVE_EXPIRED_STOCK) {
return Restrictions.and(Restrictions.ge("ii.expirationDate", new Date()), Restrictions.gt("quantityOnHand", 0))
}
return Restrictions.and(Restrictions.between("ii.expirationDate", new Date(), new Date() + expirationFilter.days), Restrictions.gt("quantityOnHand", 0))
Copy link
Member

Choose a reason for hiding this comment

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

Can you use Instant.now() and Instant.now().plus(expirationFilter.days, ChronoUnit.DAYS) instead? Do Restrictions support it?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I haven't considered Instant as I thought if we have the expirationDate as Date we should rely on Date class. Unless Instant is an extension of Date and we can use Instant methods for Date? 🤔

Copy link
Member

Choose a reason for hiding this comment

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

This should hopefully be independent from that since all we're using it for here is to query the db and the JDBC already supports Instant

Copy link
Collaborator

@awalkowiak awalkowiak left a comment

Choose a reason for hiding this comment

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

I am marking this as a change request instead of a comment, to make sure we discuss one issue that I see

List<ReorderReportItemDto> getReorderReport(ReorderReportFilterCommand command) {
// Find inventory levels that don't have bin location set and have AT LEAST one of min/max qty set
List<InventoryLevel> inventoryLevels = InventoryLevel.createCriteria().list {
eq("inventory", AuthService.currentLocation.inventory)
Copy link
Collaborator

@awalkowiak awalkowiak Oct 27, 2025

Choose a reason for hiding this comment

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

I thinkt it would be worth to have it in params (command), and if not specified then a fallback to current location. So user can technically request a report for another location without manually changing it.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Ok now I see that you have the additionalLocations, so why it is not used here? You are pulling the inventory levels only for the current location, but later it looks like you are mixing levels for products from additional locations without having the levels for the correct inventories here. Is this true and expected or am I missing something here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

As discussed on Slack, this behavior is expected, that the current location is the "central" one, and for additionalLocations we only want to sum the qatp - the inventory level values should be taken from the central locations and shouldn't include values from additionalLocations.
I will add a comment in the code explaining that as it might be confusing

@kchelstowski
Copy link
Collaborator Author

Marking as do not merge as I'm investigating one minor issue found by Alan while testing his frontend with cherry-picked commits from this PR.

@kchelstowski kchelstowski added the warn: do not merge Marks a pull request that is not yet ready to be merged label Oct 27, 2025
…inventoryLevel call only for current location
@kchelstowski kchelstowski removed the warn: do not merge Marks a pull request that is not yet ready to be merged label Oct 27, 2025
@alannadolny alannadolny merged commit f8deac9 into develop Oct 28, 2025
7 checks passed
@alannadolny alannadolny deleted the ft/OBPIH-7542 branch October 28, 2025 09:41
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 domain: l10n Changes or discussions relating to localization & Internationalization type: feature A new piece of functionality for the app

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants