Skip to content

OBPIH-7544 Create backend for expiration history report (list endpoin…#5581

Merged
alannadolny merged 2 commits intodevelopfrom
ft/OBPIH-7544
Oct 31, 2025
Merged

OBPIH-7544 Create backend for expiration history report (list endpoin…#5581
alannadolny merged 2 commits intodevelopfrom
ft/OBPIH-7544

Conversation

@kchelstowski
Copy link
Collaborator

…t + download csv)

✨ Description of Change

Link to GitHub issue or Jira ticket:

Description:


📷 Screenshots & Recordings (optional)

@kchelstowski kchelstowski self-assigned this Oct 29, 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 29, 2025
Comment on lines 7 to 8
Date dateFrom
Date dateTo
Copy link
Collaborator

Choose a reason for hiding this comment

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

Maybe it would be better to call it startDate and endDate to keep it consistent with the rest of our app? For example in InventoryAuditCommand and CycleCountReportCommand we use those names, and Alan’s frontend seems to be set up for them as well 🤔

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Actually we don't have it standardized, as we use createdFrom/createdTo also, along with startDate/endDate - we probably have to decide on the convention, so thanks for pointing that out

List<TransactionEntry> entries = TransactionEntry.createCriteria().list(offset: command.offset, max: command.max) {
transaction {
// Expired transaction type is hardcoded with id = "4"
eq("transactionType", TransactionType.read("4"))
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can’t we do something it like this?
eq("transactionType", TransactionType.read(Constants.EXPIRATION_TRANSACTION_TYPE_ID))

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 catch, the query was quickly written hence I forgot we had consts for that.

}

PaginatedList<ExpirationHistoryReportRow> getExpirationHistoryReport(ExpirationHistoryReportFilterCommand command) {
List<TransactionEntry> entries = TransactionEntry.createCriteria().list(offset: command.offset, max: command.max) {
Copy link
Member

Choose a reason for hiding this comment

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

offset and max are nullable in PaginationParams so best to check for non-null before using them.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

no need to do it - if they are not provided, no pagination will be applied

import grails.validation.Validateable
import org.pih.warehouse.core.PaginationParams

class ExpirationHistoryReportFilterCommand extends PaginationParams implements Validateable {
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 fan of extending PaginationParams here. I see class inherritance as "childClass is a parentClass" so not really applicable here. I also try to avoid it when possible because you can only ever extend one class, so it could get us stuck in the future if we want to extend something else.

You're essentially using PaginationParams as a way to group commonly used params so why not make it a field in ExpirationHistoryReportFilterCommand instead?

Or if you really want to use inherritance you could do it via traits (which I see as "childClass has attributes of trait":

trait PaginatedQuery {
    PaginationParams paginationParams
}

class ExpirationHistoryReportFilterCommand implements PaginatedQuery { ... }

This saves us from being stuck extending from something and so in the future if we want to implement other common traits, we can.

I don't know how that exactly works for triggering validation 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.

Hm, good point @ewaterman. I have always struggled with a proper way to use inheritance. Would you then say that wherever we extend the PaginationParams or PaginationCommand we do a mistake? Now that you've just said we could just use it as a field, I started to wonder when we actually would prefer extension over having a field. I'd like to know more about that so if you have any quick examples I'd be grateful.

Copy link
Member

@ewaterman ewaterman Oct 30, 2025

Choose a reason for hiding this comment

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

My general rule is:

  • can it be represented as "X HAS Y" -> use traits or fields
  • if it only makes sense to be represented as "X IS (and only is) Y" -> use inherritance

So in this case, ExpirationHistoryReportFilterCommand HAS PaginationParams.

Confusingly, you could also say ExpirationHistoryReportFilterCommand IS a PaginatedCommand, but is it ONLY a PaginatedCommand? I don't think so.

I prefer to use traits/fields over abstract inherritance whenever possible because inherritance is restrictive. If we use extends and then later want to introduce other common behaviour to the command, then we're stuck.

For example, lets say we try to get smart and create a common way to filter stuff:

abstract class FilteredCommand implements Validateable {
    List<Filter> filters

    static constraints = { ... }
}

class Filter implements Validateable {
    FilterRule type   // an enum with GREATER_THAN, LESS_THAN, BEFORE, AFTER...
    String fieldName
    String filterValue

    static constraints = { ... }
}

We want to do ExpirationHistoryReportFilterCommand extends PaginatedCommand, FilteredCommand but we can't because you can only extend a single class!

To solve this I'd probably convert both into traits (I haven't tested this code so no idea how it works with constraints):

ExpirationHistoryReportFilterCommand implements Paginatable, Filterable { ... }

trait Paginatable implements Validateable { ... }
trait Filterable implements Validateable { ... }

So we could say ExpirationHistoryReportFilterCommand has the "Paginatable" and "Filterable" traits.

But when is extension preferred? I don't have the perfect answer.

As I said above, I use it when the "X is (and only is) Y" rule applies and "X has Y" doesn't make sense, but that isn't always clear. Generally I find when I'm trying to build something where there is a lot of common logic between children implementations, and that logic can't easily be broken out into helper classes/traits, then I might use extension because it's just very convenient. But that also might be a code smell that signals the logic is overly intertwined...

"csv" {
String csv = inventoryService.getExpirationHistoryReportCsv(command)
response.contentType = "text/csv"
String filename = "Expiration history report - ${AuthService.currentLocation?.name}.csv"
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're including facilityId in the URI you should probably use that instead

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's actually a good point if we should. I mean, if we should use the facility in the URI... I did it as it was pointed out during the last PR, but there is a requirement in the ticket that the location is always supposed to be the current location, so I'm wondering if we should force to have the facility id in the URI (and then have to validate that if the one provided in the URI is your current one), or just get rid of that in the URI and keep using currentLocation in the code like it is now.

Copy link
Member

Choose a reason for hiding this comment

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

that's a very good point. I think we need to make a proper decision about how and when to use the URI approach and when to rely on the session. If we always only want to use the user's current location, then yes it makes sense to not include it in the URI. But if in the future we're going to want to be able to request reports for different locations, then it makes sense to include in the URI

transaction {
// Expired transaction type is hardcoded with id = "4"
eq("transactionType", TransactionType.read("4"))
eq("inventory", AuthService.currentLocation.inventory)
Copy link
Member

Choose a reason for hiding this comment

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

same here. If you're using facilityId in the URI we should use that.

class ExpirationHistoryReportFilterCommand implements Validateable {

    Location facility

    def beforeValidate() {
        String facilityId = RequestContextHolder.getRequestAttributes().params?.facilityId
        facility = Location.findById(facilityId)
    }
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Explanation above - let's decide whether we do it this way or get rid of the facility in the URI.

order("transactionDate", "desc")
}
}
return new PaginatedList<ExpirationHistoryReportRow>(entries.collect { ExpirationHistoryReportRow.buildExpirationReportRow(it) }, entries.totalCount)
Copy link
Member

Choose a reason for hiding this comment

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

Given this is static, ExpirationHistoryReportRow.buildExpirationReportRow(it) seems like a needlessly repetitive name. How about ExpirationHistoryReportRow.from(it) or ExpirationHistoryReportRow.of(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.

how about fromTransactionEntry to have it specific?

Copy link
Member

@ewaterman ewaterman Oct 30, 2025

Choose a reason for hiding this comment

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

yeah that's fine. I feel like the X in "fromX" is implied by the type of the param. Meaning IMO:

ExpirationHistoryReportRow from(TransactionEntry transactionEntry) { ... }

already implies "fromTransactionEntry", but I'm fine either way.

valueLostToExpiry: row.valueLostToExpiry ?: "",
]
}
return CSVUtils.prependBomToCsvString(csv.writer.toString())
Copy link
Member

Choose a reason for hiding this comment

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

Since we loop through the rows anyways, I'm confused why CSVWriter requires us to do this when building the header:

"${messageLocalizer.localize("transaction.transactionNumber.label")}" { it?.transactionNumber }

and not just:

${messageLocalizer.localize("transaction.transactionNumber.label")}

Anyways, in cycle count we do this:

CSVPrinter getCycleCountCsv(List<CycleCountCandidate> candidates) {
CSVPrinter csv = CSVUtils.getCSVPrinter()
csv.printRecord(
"Code",
"Product",
"Product Family",
"Category",
"Formularies",
"ABC Classification",
"Bin Location",
"Tag",
"Last Counted",
"QoH",
)
candidates?.each { CycleCountCandidate candidate ->
csv.printRecord(
StringEscapeUtils.escapeCsv(candidate?.product?.productCode),
candidate?.product?.name ?: "",
candidate?.product?.productFamily ?: "",
StringEscapeUtils.escapeCsv(candidate?.product?.category?.name ?: ""),
candidate?.product?.productCatalogs?.join(", ") ?: "",
StringEscapeUtils.escapeCsv(candidate?.abcClass),
StringEscapeUtils.escapeCsv(candidate?.internalLocations ?: ""),
StringEscapeUtils.escapeCsv(candidate?.product?.tags?.tag?.join(", ")),
candidate?.dateLastCount?.format(Constants.EUROPEAN_DATE_FORMAT) ?: "",
candidate?.quantityOnHand ?: 0,
)
}
return csv
}

Mainly:

CSVPrinter csv = CSVUtils.getCSVPrinter()
csv.printRecord(...)

which seems easier. Any reason to do differently 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.

We don't seem to have the CSV stuff standardized so I was just following the code from one of the latest CSV. If I had seen the CSVPrinter when searching for an example, I would have probably used it as definitely it seems easier.

Answering that though:

I'm confused why CSVWriter requires us to do this when building the header:

It's because then when I add a row, I could potentially switch the order in the built map, and if I kept the property names, it would still work, so I could do:

 "${messageLocalizer.localize("transaction.transactionNumber.label")}" { it?.transactionNumber }
 "${messageLocalizer.localize("transaction.date.label")}" { it?.transactionDate }
 "${messageLocalizer.localize("product.productCode.label")}" { it?.productCode }

and then:

csv << [
   productCode: row.productCode,
   transactionDate: row.transactionDate,
   transactionNumber: row.transactionNumber,
]

Copy link
Member

Choose a reason for hiding this comment

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

thanks for the explanation. That makes sense

controller = { "inventoryApi" }
action = [GET: "getExpirationHistoryReport"]
}

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 decided to remove the facility id in the URL since this is always supposed to be current location, so we'd have more work with binding and validating the id.


class ExpirationHistoryReportFilterCommand implements Validateable {
// Pagination params have to be preinitialized to be properly bound when sent as query string
PaginationParams paginationParams = new PaginationParams()
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

this drove me nuts. it turned out you have to pre-initialize nested objects that are not domain objects (for those, the DomainClassBindingHelper comes in), otherwise no params would be bound when using query string params.
I know we can just send it in JSON body, but I think we want to support also the query string params.
The reason why it has to be preinitialized is that when binding, the paginationParams is null, so Groovy doesn't know to which object it should bind the offset/max params

import grails.validation.Validateable

abstract class PaginationParams implements Validateable {
class PaginationParams implements Validateable {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

this can't be abstract anymore, since we have to pre-initialize it in the command below

Copy link
Member

Choose a reason for hiding this comment

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

Do we also need to modify PaginationCommand then?

// Pagination params have to be preinitialized to be properly bound when sent as query string
PaginationParams paginationParams = new PaginationParams()
Date startDate
Date endDate
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@SebastianLib I went with your suggestion to have startDate/endDate

@codecov
Copy link

codecov bot commented Oct 30, 2025

Codecov Report

❌ Patch coverage is 5.26316% with 54 lines in your changes missing coverage. Please review.
✅ Project coverage is 8.46%. Comparing base (1bb7314) to head (8892ed1).
⚠️ Report is 170 commits behind head on develop.

Files with missing lines Patch % Lines
...rg/pih/warehouse/inventory/InventoryService.groovy 0.00% 32 Missing ⚠️
...rg/pih/warehouse/api/InventoryApiController.groovy 0.00% 15 Missing ⚠️
...ehouse/inventory/ExpirationHistoryReportRow.groovy 0.00% 5 Missing ⚠️
...entory/ExpirationHistoryReportFilterCommand.groovy 0.00% 2 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             develop   #5581      +/-   ##
============================================
- Coverage       9.12%   8.46%   -0.67%     
+ Complexity      1170    1105      -65     
============================================
  Files            701     709       +8     
  Lines          45281   45505     +224     
  Branches       10851   10893      +42     
============================================
- Hits            4131    3850     -281     
- Misses         40497   41083     +586     
+ Partials         653     572      -81     

☔ 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.

@alannadolny alannadolny merged commit d5e9de3 into develop Oct 31, 2025
7 checks passed
@alannadolny alannadolny deleted the ft/OBPIH-7544 branch October 31, 2025 09:57
@sentry
Copy link

sentry bot commented Nov 5, 2025

Issues attributed to commits in this pull request

This pull request was merged and Sentry observed the following issues:

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.

4 participants