Skip to content

OBPIH-6527 Full outbound import - validation & save endpoints #4775

Merged
awalkowiak merged 13 commits intofeature/outbound-importfrom
OBPIH-6527-impl
Aug 13, 2024
Merged

OBPIH-6527 Full outbound import - validation & save endpoints #4775
awalkowiak merged 13 commits intofeature/outbound-importfrom
OBPIH-6527-impl

Conversation

@kchelstowski
Copy link
Collaborator

✨ Description of Change

The PR contains the validation (and import file) endpoints as promised. The creation/save endpoint is still in progress.

Link to GitHub issue or Jira ticket: https://pihemr.atlassian.net/browse/OBPIH-6527

Description:


📷 Screenshots & Recordings (optional)


📈 Test Plan

We require that all code changes come paired with a method of testing them. Please select which of the following testing approaches you've included with this change:

  • Frontend automation tests (unit)
  • Backend automation tests (unit, API, smoke)
  • End-to-end tests (Playwright)
  • Manual tests (please describe below)
  • Not Applicable

Description of test plan (if applicable):


✅ Quality Checks

Please confirm and check each of the following to ensure that your change conforms to the coding standards of the project:

  • The pull request title is prefixed with the issue/ticket number (Ex: [OBS-123] for Jira, [#0000] for GitHub, or [OBS-123, OBPIH-123] if there are multiple), or with [N/A] if not applicable
  • The pull request description has enough information for someone without context to be able to understand the change and why it is needed
  • The change has tests that prove the issue is fixed / the feature works as intended

@kchelstowski kchelstowski added status: work in progress For issues or pull requests that are in progress but not yet completed warn: do not merge Marks a pull request that is not yet ready to be merged domain: backend Changes or discussions relating to the backend server labels Aug 2, 2024
@kchelstowski kchelstowski self-assigned this Aug 2, 2024
@github-actions github-actions bot added flag: schema change Hilights a pull request that contains a change to the database schema domain: l10n Changes or discussions relating to localization & Internationalization labels Aug 2, 2024
Comment on lines +67 to +77
static Person findPersonByNameOrEmail(String regex) {
String[] searchTerm = regex?.split(Constants.SPACE_SEPARATOR)
// If search term contains two words, try to search by first name or last name first
if (searchTerm?.length == 2) {
return findByFirstNameAndLastName(searchTerm[0], searchTerm[1])
}
if (searchTerm?.length == 1) {
return findByEmail(searchTerm[0])
}
return null
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

I know it was done like that before, so I am not requesting change, but maybe we should create a ticket for improving this search? For me, it's completely broken, what if:

  • someone enter more than 2 words?
  • someone enter capitalized words?

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 agree it could be much better, and I was also surprised how it is currently handled, but I've just copied the logic from current imports.

Copy link
Member

Choose a reason for hiding this comment

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

Yeah the person finder logic is naive at best and doesn't handle most of the complex use cases we encounter.

There have been tickets that discuss the limitations of the current solution and start to discuss the complexity of a proper solution (most recently this https://pihemr.atlassian.net/browse/OBPIH-5972).

But similar to other search use cases, in the long-term we shouldn't be handle search ourselves if we can help it. This would be better handled by a search index, like Elasticsearch.

For the import case, I would love it if we could require an email or username to avoid the issues with the name search. We should discuss that with Manon and Kelsey. Otherwise this method needs to fail-fast if unique values aren't found (we should throw an error if the given input returns 0 or more than 1 results are found).

In general, we should also avoid duplicating logic if it exists elsewhere. Moving this to the Person domain makes sense (see code in PersonService as well). But if we're moving logic to Person we should have a plan for migrating the other code as well.

Copy link
Collaborator

Choose a reason for hiding this comment

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

do we have a final decision to create migrations in XML instead of Groovy? I forgot how this discussion went.

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 assumed we would eventually come back to xml again after the discussion we had had, so I went with the xml.

Copy link
Member

Choose a reason for hiding this comment

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

Yes, XML.

eq("location", location)
if (binLocation) {
eq("binLocation", binLocation)
return
Copy link
Collaborator

Choose a reason for hiding this comment

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

Does this return work? 😄

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

yeah, since we don't have any additional statements below (except isNull which we would not want to be executed if bin location is present), this works fine.

Copy link
Member

Choose a reason for hiding this comment

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

I think I understand what you're doing, but I don't know why. It's confusing, not documented, and could get us in trouble if someone decides to remove it because it looks superfluous.

Change it to be more readable:

if (binLocation) {
    eq("binLocation", binLocation)
} else {
    isNull("binLocation")
}

ApplicationTagLib g = Holders.grailsApplication.mainContext.getBean(ApplicationTagLib)
errors.allErrors
.groupBy { FieldError error -> error.field }
.collectEntries { [it.key, it.value.collect{ error -> g.message(error: error)}] } as Map<String, List<String>>
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
.collectEntries { [it.key, it.value.collect{ error -> g.message(error: error)}] } as Map<String, List<String>>
.collectEntries { [it.key, it.value.collect { error -> g.message(error: error)}] } as Map<String, List<String>>

Copy link
Collaborator

Choose a reason for hiding this comment

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

We can also move the g.message to the new line.

Comment on lines +19 to +27
A: "palletName",
B: "boxName",
C: "productCode",
D: "product.name",
E: "lotNumber",
F: "expirationDate",
G: "binLocation",
H: "quantityPicked",
I: "recipient"
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is the indentation correct?

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 followed the pattern from already existing importer classes

Comment on lines 31 to 37
Location location = Location.findByNameOrDefault(source['binLocation'])
// If location is not found, but we provided bin location name, it means, the location was not found,
// and we want to indicate that, instead of falling back to default (null)
// without this, we would then search e.g. for quantity available to promise for a product in the default bin location
if (!location && source['binLocation'] && !source['binLocation'].equalsIgnoreCase(Constants.DEFAULT_BIN_LOCATION_NAME)) {
// We want to indicate, that a bin location for given name has not been found.
obj.binLocationFound = false
Copy link
Collaborator

Choose a reason for hiding this comment

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

wouldn't it be better to move it to something like EmptyStringsToNullBinder.groovy? I would like to avoid adding a lot of code between properties, seeing this I am expecting to see methods under this, not the rest of the properties. But it can be just my opinion.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

yeah, this could be moved to a method, for now I've just focused on the functionality, but when I will do the final cleanup, I will take this suggestion into account.

if (!inventoryItem) {
return ['inventoryItemNotFound']
}
Location currentLocation = AuthService.currentLocation
Copy link
Collaborator

Choose a reason for hiding this comment

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

is there a benefit from assigning it to the variable? The name of the property already suggests that it is a current 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.

probably there isn't, it just felt better for me to pass a variable to the method below instead of passing the AuthService.currentLocation, but I could agree that for this particular case it probably doesn't make sense to create the variable

Date expirationDate

@BindUsing({ obj, source ->
Location location = Location.findByNameOrDefault(source['binLocation'])
Copy link
Collaborator

Choose a reason for hiding this comment

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

You should look for a bin location name under a specific parent. I think this might be the main reason why you got the performance issues. Bin location name is unique under a specific parent but can be duplicated globally, in this version you can't be sure that you found the bin location that you wanted to find.

Copy link
Collaborator

@awalkowiak awalkowiak Aug 2, 2024

Choose a reason for hiding this comment

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

Well maybe it is not the main reason of performance issues (it is), but definitely looking by name is not enough in terms what you are trying to find 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.

@awalkowiak yeah, something smelled to me as well. I mean - the performance issues are surely due to the fact that we have not had any index for the name column (because we search for only one location, and searching via id, that has index works fast), but if I understand you correctly, I should look for a location under origin's locations, so:
origin.getInternalLocation(source['binLocation'])?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yes this one would be good

property("quantityAvailableToPromise")
}
eq("inventoryItem", inventoryItem)
eq("location", 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.

have just realized after @awalkowiak 's comment about binLocation, that I should probably check for the quantity atp for the origin, not the current 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.

in 99% scenarios, the current location would be origin as it is filled in by default, but I assume we want to allow changing the origin by a user?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

yeah, I've just checked how it works for the regular outbound (we allow to change the origin), so I'll leave that note for me, not to forget to change this line to the origin.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yes if possible please rely on the origin and destination directly

<indexExists indexName="location_name_idx"/>
</not>
</preConditions>
<createIndex indexName="location_name_idx" tableName="location" unique="false">
Copy link
Collaborator

Choose a reason for hiding this comment

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

If you look for bin by parent and name, then this should not be required

Copy link
Member

Choose a reason for hiding this comment

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

Why do you say that @awalkowiak?

If we're doing any queries with the name column and comparison or logical operators (=, like, ilike) and not using wrapped regex matching (name ilike "%term%"), then an index is probably a good idea.

Copy link
Member

Choose a reason for hiding this comment

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

Now that I've read through the entire review I'm understanding this a bit better. And while Artur is right that it probably isn't necessary for this PR, we should have an index on location name. However, we need to look into how we're using the name column when querying locations.

For example, if we always query name and another column together then we'd want to create a composite index. Even if you do some queries with both columns and some queries with just one column, MySQL should use the composite index. So it's better to have the composite index then two separate indexes.

So while there's definitely a need to create an index on location name, we might want to investigate this as a separate issue as long as the performance issue (alluded to below) is no longer an issue.

Copy link
Member

Choose a reason for hiding this comment

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

Yes, XML.

<indexExists indexName="location_name_idx"/>
</not>
</preConditions>
<createIndex indexName="location_name_idx" tableName="location" unique="false">
Copy link
Member

Choose a reason for hiding this comment

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

Why do you say that @awalkowiak?

If we're doing any queries with the name column and comparison or logical operators (=, like, ilike) and not using wrapped regex matching (name ilike "%term%"), then an index is probably a good idea.

<include file="0.9.x/changelog-2024-04-29-1200-alter-table-picklist-item-add-quantity-picked.groovy" />
<include file="0.9.x/changelog-2024-05-08-1110-rename-picker-to-pickedBy-column.groovy" />
<include file="0.9.x/changelog-2024-06-27-1200-add-auto-allocated-column-to-requisition-item.groovy" />
<include file="0.9.x/changelog-2024-08-02-1200-add-location-name-index.xml" />
Copy link
Member

Choose a reason for hiding this comment

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

TL;DR: I've seen this a bunch of times and want to correct it. So in case it's not clear, this must be changed before merging.

While it's probably not documented, there is a convention established (previous database migrations) that I would like to follow.

If you include the indexName in the changeset, then the name of the file should be:

<include file="0.9.x/changelog-2024-08-02-1200-add-index-location-name-idx.xml" />

If you don't include the indexName (assuming it can be optional) then it would be:

<include file="0.9.x/changelog-2024-08-02-1200-add-index-location-name.xml" />

Other examples

add-column-<tableName>-<columnName>.xml
add-index-<indexName>.xml
add-index-<tableName>-<columnName>.xml
create-table-<tableName>.xml

For example, two changesets above this one should have been

<include file="0.9.x/changelog-2024-05-08-1110-rename-column-picker-id.groovy" />
<include file="0.9.x/changelog-2024-06-27-1200-add-column-requisition-item-auto-allocated.groovy" />

return quantityAvailableToPromiseByProductNotInBin ?: 0
}

Integer getQuantityAvailableToPromiseForProductInBin(InventoryItem inventoryItem, Location location, Location binLocation) {
Copy link
Member

Choose a reason for hiding this comment

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

  1. Change the order of the method arguments. The method above this one shows the right order for the method arguments.
  2. Shorten these methods names to be a little more concise.
getQuantityAvailableToPromise(Location location, Location binLocation, InventoryItem inventoryItem)

Integer getQuantityAvailableToPromiseForProductInBin(InventoryItem inventoryItem, Location location, Location binLocation) {
return ProductAvailability.createCriteria().get {
projections {
property("quantityAvailableToPromise")
Copy link
Member

Choose a reason for hiding this comment

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

TL;DR: This needs to be dealt with in this PR.

While it might not seem possible for there to be multiple rows per location/binLocation/inventoryItem, we can't make that assumption unless it's actually true and unless the database prevents multiple rows from being added to the table.

The following query was executed on production. I haven't looked into whether this is valid or invalid. But the fact that the query returns two separate instance with two records indicates that the table allows multiple rows per location, bin location, inventory item.

MariaDB [openboxes]> select location_id, bin_location_id, inventory_item_id, count(*) from product_availability group by location_id, bin_location_id, inventory_item_id having count(*) > 1\G
*************************** 1. row ***************************
      location_id: 8a8a9e9665c4f59d0165c5516779002f
  bin_location_id: NULL
inventory_item_id: ff8080818662c5070187049481ef7a3d
         count(*): 2
*************************** 2. row ***************************
      location_id: 8a8a9e9665c4f59d0165c558178f0041
  bin_location_id: 8a8a9e9669680d660169725db72521b2
inventory_item_id: 8a8a9e9669680d660169725d888c2163
         count(*): 2
2 rows in set (0.990 sec)

The code needs to handle this case, so please investigate why there could be two records and determine whether it's:

  • a valid case
    • we probably just need to sum the QATP across the records
  • an invalid case
    • we need to workaround the issue to reduce the duplicate records to one record
    • add a ticket to discover why it's happening and prevent 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.

@jmiranda this is the issue I've once fixed: https://pihemr.atlassian.net/browse/OBPIH-6008
I've run your query on my local db, and then checked all the columns how they look like.
Here is an example:

id                                  |version|bin_location_id                 |inventory_item_id               |location_id                     |product_id|product_code|lot_number  |bin_location_name|quantity_allocated|quantity_on_hand|date_created       |last_updated       |quantity_on_hold|quantity_available_to_promise|
------------------------------------+-------+--------------------------------+--------------------------------+--------------------------------+----------+------------+------------+-----------------+------------------+----------------+-------------------+-------------------+----------------+-----------------------------+
0ac391ad-4d6d-4a27-bc01-77139d1fd332|      0|09f5e116831aefb00183377b1e5d58c4|09f5e1167c3c9801017c4ba7755715fe|09f5e1167e6ee8ec017e9c3c480f6f43|10001     |21064       |MWM062620N47|R-307CFC         |                 0|               0|2023-08-30 18:09:01|2023-08-30 18:09:01|               0|                            0|
f47db6bf-6ee9-4d73-8a3a-f7c58e39f5dc|     22|09f5e116831aefb00183377b1e5d58c4|09f5e1167c3c9801017c4ba7755715fe|09f5e1167e6ee8ec017e9c3c480f6f43|10001     |10001       |MWM062620N47|R-307CFC         |                 0|               0|2023-09-18 21:01:21|2023-10-25 21:18:03|               0|                            0|

so what needs to be done on production is to refresh PA on the locations where those rows exist

Copy link
Member

Choose a reason for hiding this comment

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

@kchelstowski Although you've fixed the data issue, the problem is that this scenario can still happen. So we need to understand how it happens and be defensive about how to handle it.

@awalkowiak Let me know if you have any thoughts. It might not be fixable but we shouldn't ignore it if it's a possibility.

return data.collect { [
palletName: it?.palletName,
boxName: it?.boxName,
product: it["productCode"],
Copy link
Member

Choose a reason for hiding this comment

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

Let's call the property "productCode" in order to ensure there's no confusion.

The other fields (like binLocation) are fine, because there's ambiguity to what data will be in the Bin Location column (i.e. id, code, name of bin location).

But there's no ambiquity in the Product Code column.

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 called it product, so that I would not have to do any mapping on the frontend side in order to bind the product in the ImportPackingListItem.
I will change that, and will just adjust the frontend, so that for the validation endpoint we would send this property as:

product: record.productCode

return ['noStock']
}
if (quantityPicked > quantity) {
return ['overPick', quantityPicked]
Copy link
Member

Choose a reason for hiding this comment

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

name this overpick since it's an actual word and not two words "over pick"). This is similar to "putaway". It's putaway, not "put away".

Copy link
Member

Choose a reason for hiding this comment

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

return ['inventoryItemNotFound']
}
Location currentLocation = AuthService.currentLocation
Integer quantity = productAvailabilityService.getQuantityAvailableToPromiseForProductInBin(inventoryItem, currentLocation, item.binLocation)
Copy link
Member

Choose a reason for hiding this comment

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

I understand the desire to have this here in teh constraints. But this seems dangerous. I think I'd prefer if the validateData was doing this validation. One benefit would be that we'd inject the productAvailabilityService into the Importer once vs once each time domain.validate() is called (likely more than once per domain).

With that said, there an argument for allowing the domain to call the service.
https://stackoverflow.com/a/3654488/136597
https://stackoverflow.com/a/2460029/136597

But perhaps we could inject the service into the domain once
https://stackoverflow.com/a/9699609/136597

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 decided not to perform the validation at import file level, hence we can't do it in the validateData method, as it would require us to refactor the whole thing now.
The productAvailabilityService is not injected per each item, since for each item we have the same memory reference to the service, so for each item it uses "the same" instance of product availability service.

Location currentLocation = AuthService.currentLocation
Integer quantity = productAvailabilityService.getQuantityAvailableToPromiseForProductInBin(inventoryItem, currentLocation, item.binLocation)
if (!quantity) {
return ['noStock']
Copy link
Member

Choose a reason for hiding this comment

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

let's call this one
stockout or unavailable.

ANd make sure that !quantity returns true for both 0 and < 0.

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 about the quantity < 0. I thought it's not possible to have negative quantity in the product availability table.

<indexExists indexName="location_name_idx"/>
</not>
</preConditions>
<createIndex indexName="location_name_idx" tableName="location" unique="false">
Copy link
Member

Choose a reason for hiding this comment

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

Now that I've read through the entire review I'm understanding this a bit better. And while Artur is right that it probably isn't necessary for this PR, we should have an index on location name. However, we need to look into how we're using the name column when querying locations.

For example, if we always query name and another column together then we'd want to create a composite index. Even if you do some queries with both columns and some queries with just one column, MySQL should use the composite index. So it's better to have the composite index then two separate indexes.

So while there's definitely a need to create an index on location name, we might want to investigate this as a separate issue as long as the performance issue (alluded to below) is no longer an issue.

@github-actions github-actions bot added the domain: frontend Changes or discussions relating to the frontend UI label Aug 8, 2024
sendingOptions,
})).then((response) => {
window.location = STOCK_MOVEMENT_URL.show(response.data.data.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.

@awalkowiak don't look at this file at all. It's just a 🍝 on my testing purposes. I will make it cleaner etc in the frontend ticket.
I decided not to remove it though, to make the feature testable for Kasia and Manon

@kchelstowski kchelstowski changed the title OBPIH-6527 WIP: Full outbound import - validation & save endpoints OBPIH-6527 Full outbound import - validation & save endpoints Aug 8, 2024
@kchelstowski kchelstowski removed the status: work in progress For issues or pull requests that are in progress but not yet completed label Aug 8, 2024
@kchelstowski kchelstowski removed the warn: do not merge Marks a pull request that is not yet ready to be merged label Aug 8, 2024
@kchelstowski
Copy link
Collaborator Author

This one is ready for review

errors.packingList[item.rowId] = CommandUtils.buildErrorsGroupedByField(item.errors)
}
}
}
Copy link
Member

@ewaterman ewaterman Aug 8, 2024

Choose a reason for hiding this comment

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

I like the thought process here. Maybe someday we can find a way to apply a more generalized version across the app so that we can have a common format for our error responses.

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 we actually have a generalized version - we have errors handler (ErrorsController). I'd say this is just a new case, where instead of throwing a popup error, we want to associate an error with a particular row in the table.

Copy link
Member

Choose a reason for hiding this comment

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

Can we move the build errors into CommandUtils as well?

static constraints = {
expectedShippingDate(validator: { value, obj ->
// Check if shipping date happens before the expected delivery date (if delivery date specified)
if (obj.expectedDeliveryDate && value.after(obj.expectedDeliveryDate + 1)) {
Copy link
Member

@ewaterman ewaterman Aug 8, 2024

Choose a reason for hiding this comment

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

Is the +1 because value.after doesn't handle them being equal? What happens if delivery date and shipment date are the same?

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 yeah, a similar addition is implemented in the Shipment domain.

return data.collect { [
palletName: it?.palletName,
boxName: it?.boxName,
productCode: it["productCode"],
Copy link
Member

Choose a reason for hiding this comment

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

why is productCode being accessed differently?

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 question - previously I was probably accessing some deeper associations (or copied it from somewhere) and didn't realize I've mixed the type of access. I will unify it.

Comment on lines 1079 to 1080
fulfillmentRequest.origin.invalid=Origin can't be the same as destination
fulfillmentRequest.destination.invalid=Destination can't be the same as origin
Copy link
Member

@ewaterman ewaterman Aug 8, 2024

Choose a reason for hiding this comment

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

Suggested change
fulfillmentRequest.origin.invalid=Origin can't be the same as destination
fulfillmentRequest.destination.invalid=Destination can't be the same as origin
fulfillmentRequest.origin.sameAsDestination=Origin can't be the same as destination
fulfillmentRequest.destination.sameAsOrigin=Destination can't be the same as origin

Because "invalid" is very general but the error is a very specific error scenario. This way leaves room for us to add further error messages on the origin/destination in the future

# Messages related to outbound import
fulfillmentRequest.origin.invalid=Origin can't be the same as destination
fulfillmentRequest.destination.invalid=Destination can't be the same as origin
fulfillmentRequest.destination.nullable=Destination cannot be null
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
fulfillmentRequest.destination.nullable=Destination cannot be null
fulfillmentRequest.destination.nullable=Destination cannot be empty

I feel like a non-developer would understand "empty" more than "null". Same comment for all these.

if (!item.binLocationFound) {
return ['binLocationNotFound']
}
ProductAvailabilityService productAvailabilityService = Holders.grailsApplication.mainContext.getBean(ProductAvailabilityService)
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 big fan of having to pull a service in like this to do validation. IMO request objects should be simple and not depend on any other large classes.

My preferred approach to handling situations like this is to add an ImportPackingListValidator that can autowire in whatever classes it needs.

That way request objects can act more like simple POJOs. But maybe there's a better way to achieve this via Grails that I don't know about.

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 okay with doing it this way for now, but I would like to brainstorm some potential longterm solutions

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 didn't want to introduce a validator class or proceed with the validation in the service for this one property, not to mix two approaches - validator, and a custom validation in a validator class.
I know injecting a service into a domain is debatable, but since Grails' recommended validation happens in the domain (validator), I've gone this way.
As I mentioned somewhere above - this doesn't "recreate" the product availability service, or anything like this, because the PA service is singleton, and we always get the same reference no matter which item we are iterating through.

}

StockMovement createOutbound(ImportPackingListCommand command) {
// First create a requisition
Copy link
Member

Choose a reason for hiding this comment

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

IMO you probably don't need a lot of these comments since they're just reiterating what the method names are (which means you have good method names). My general approach is to have self-documenting code (ie writing code in a way that makes the functionality obvious by just reading the code) and then add comments if it's still unclear.

Not a big deal though and better to be overly verbose than have very unclear code.

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 also had a doubt whether to put those comments or not, but thought that maybe it's better to explain the iteration explicitly.

}
return name
.toString()
.replace(Constants.SPACE_SEPARATOR, "")
Copy link
Member

Choose a reason for hiding this comment

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

Not really important but SPACE_SEPARATOR is somewhat misleading here since you're actually just trimming all white space from a string and it has nothing to do with the separator character.

I'd make this a StringUtils.removeWhiteSpace(String s) utility method that we can re-use anywhere.

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.

Two minor things

return null
}

static String removeWhiteSpace(String s) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why not StringUtils.deleteWhitespace or StringUtils.deleteSpace?

Copy link
Collaborator Author

@kchelstowski kchelstowski Aug 12, 2024

Choose a reason for hiding this comment

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

I went with @ewaterman's suggestion


Map<String, List<String>> sendingOptions

Map<String, Map<String, Map<String, List<String>>>> packingList = new HashMap<>()
Copy link
Collaborator

Choose a reason for hiding this comment

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

This nested map is confusing, perhaps a comment with an example could be useful.

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 believe I introduced a comment in the controller itself:

// Build errors for each row separately. Row id is the key.
 // e.g. { 2: { binLocation: ["Bin location cannot be null"] } }
errors.packingList[item.rowId] = CommandUtils.buildErrorsGroupedByField(item.errors)

but I don't mind adding it also here for clarity

@kchelstowski kchelstowski changed the base branch from develop to feature/outbound-import August 13, 2024 06:55
@awalkowiak awalkowiak merged commit b8f92d2 into feature/outbound-import Aug 13, 2024
@awalkowiak awalkowiak deleted the OBPIH-6527-impl branch August 13, 2024 08:51
Copy link
Member

@jmiranda jmiranda left a comment

Choose a reason for hiding this comment

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

@jmiranda to move comments to the new PR.

#4786

Comment on lines +67 to +77
static Person findPersonByNameOrEmail(String regex) {
String[] searchTerm = regex?.split(Constants.SPACE_SEPARATOR)
// If search term contains two words, try to search by first name or last name first
if (searchTerm?.length == 2) {
return findByFirstNameAndLastName(searchTerm[0], searchTerm[1])
}
if (searchTerm?.length == 1) {
return findByEmail(searchTerm[0])
}
return null
}
Copy link
Member

Choose a reason for hiding this comment

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

Yeah the person finder logic is naive at best and doesn't handle most of the complex use cases we encounter.

There have been tickets that discuss the limitations of the current solution and start to discuss the complexity of a proper solution (most recently this https://pihemr.atlassian.net/browse/OBPIH-5972).

But similar to other search use cases, in the long-term we shouldn't be handle search ourselves if we can help it. This would be better handled by a search index, like Elasticsearch.

For the import case, I would love it if we could require an email or username to avoid the issues with the name search. We should discuss that with Manon and Kelsey. Otherwise this method needs to fail-fast if unique values aren't found (we should throw an error if the given input returns 0 or more than 1 results are found).

In general, we should also avoid duplicating logic if it exists elsewhere. Moving this to the Person domain makes sense (see code in PersonService as well). But if we're moving logic to Person we should have a plan for migrating the other code as well.

return "$firstName ${anonymize ? lastInitial : lastName}"
}

static Person findPersonByNameOrEmail(String regex) {
Copy link
Member

Choose a reason for hiding this comment

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

Convention: If the method is in a domain, the method name should not include the name of the domain.

Person findByNameOrEmail(String searchTerms)

Editorial Note: We should have lots of tests for this to show what is possible and what is not possible.

Lastly, "regex" seems unintutitive for what is being passed here. The argument is searchTerms not a regular expression, right?

importFile.transferTo(localFile)
DataImporter packingListImporter = new PackingListExcelImporter(localFile.absolutePath)

render([data: packingListImporter.toJson() ] as JSON)
Copy link
Member

Choose a reason for hiding this comment

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

Interesting idea to add the toJson to the importer object. I would have expected this to be the resposibility of the command object. I need to think about this more, but it feels like the importer should be responsible for parsing the data and the command object should be responsible for rendering the data.

I could be wrong. Maybe the importer is a glorified command object itself? Anyone else have any thoughts?

In general, it feels like we'll want to rememeber this when we refactor import/export since we want to be able to import and export in different formats. I'm not sure if the importer / exporter should be responsible for that. Instead we delegate that to a class that implements a Strategy pattern (or maybe Adapter pattern).

https://pihemr.atlassian.net/browse/OBIG-4

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 for the importer itself, I was just following the pattern we already have, as it was painful to make it work. I agree there is a bit of mess with the exporters/importers and this should be refactored at some point, but answering your question I'd say we could treat the importer class as the command, since it contains the data

render([data: stockMovement] as JSON)
}

static void buildErrors(ImportPackingListCommand command, ImportPackingListErrors errors) {
Copy link
Member

Choose a reason for hiding this comment

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

The command class should be responsible for building / returning it's own errors.

command.packingList.each { ImportPackingListItem item ->
if (item.hasErrors()) {
// Build errors for each row separately. Row id is the key.
// e.g. { 2: { binLocation: ["Bin location cannot be null"] } }
Copy link
Member

Choose a reason for hiding this comment

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

I know we discussed this in the design, but I thought we were heading in a different direction. Is there a need for structure to include parent / child where the row number is the parent index? I feel like a more straightforward JSON errors object would be more intuitive.

errors: { 
    { 
        rowNumber: 2,
        property: "binLocation",
        errorMessages: {
            default: "Bin location cannot be null",
            en: "Bin location cannot be null",
        }
    },
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@jmiranda having the rowId as the key makes it easier to access.
If we went with the approach you've just proposed, we would have to do parse it on the frontend using some Object.entires/Object.keys in order to access all errors of a row, which would make the operation O(n^2), not O(1) as it is now (accessing a key of a Map is O(1)), so I expect that we could experience some performance issues if there were a lot of rows.

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: frontend Changes or discussions relating to the frontend UI domain: l10n Changes or discussions relating to localization & Internationalization flag: schema change Hilights a pull request that contains a change to the database schema

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants