Skip to content

OBPIH-6155 Implement picklist manual import (correct stock)#4625

Merged
jmiranda merged 17 commits intodevelopfrom
OBPIH-6155-ability-to-create-edit-a-pick-via-import-correct-stock-case
May 25, 2024
Merged

OBPIH-6155 Implement picklist manual import (correct stock)#4625
jmiranda merged 17 commits intodevelopfrom
OBPIH-6155-ability-to-create-edit-a-pick-via-import-correct-stock-case

Conversation

@drodzewicz
Copy link
Collaborator

@drodzewicz drodzewicz commented May 16, 2024

Since we have not settled on any specific pattern for import I decided to store all of the logic in the Service layer and later we can just move it when refactoring the importers.

@drodzewicz drodzewicz self-assigned this May 16, 2024
@drodzewicz drodzewicz changed the title OBPIH-6155 Implement picklist manual import OBPIH-6155 Implement picklist manual import (correct stock) May 16, 2024
@awalkowiak awalkowiak changed the base branch from feature/upgrade-to-grails-3.3.10 to develop May 17, 2024 11:46
}.flatten()

stockMovementService.createOrUpdatePicklistItem(stockMovement, pickPageItems)
stockMovementService.importPicklistTemplate(importedLines, stockMovement, pickPageItems)
Copy link
Collaborator

Choose a reason for hiding this comment

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

why we first import the template and then check if the errors are not empty?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

some of the requirements for import are

if I enter a stock record that does not exist in inventory, then the pick isn’t imported for these incorrect lines, and all valid lines show the pick from the import successfully and the invalid lines remain empty and are highlighted in red to complete the pick

which means that user expect to import valid lines and get errors for invalid ones.

The way Manon explained this to me is that user might import the file, export pick file, fix rows that were invalid and were not imported and then import the file again.

Basically main point is that we don't want to fail the import if there are invalid lines.
If you guys disagree with that, then this needs to be discussed with Manon and Kelsey.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Maybe from a user perspective it does have a sense, but from a best practices/REST/anything perspective, this smells to me.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Moreover, prove me wrong, but in case of situation when a few items have errors and user would want to fix them, I assume the user wouldn't remove the valid ones (those that are already persisted) from the sheet.
Would we actually update an existing item or we would create a new pick list item?
Looking quickly at the code, I'm concerned that we actually wouldn't update the existing item.

Copy link
Member

@jmiranda jmiranda May 24, 2024

Choose a reason for hiding this comment

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

Sorry, for some reason I'm not seeing all comments when I click on Files Changed tab so I missed this one the first time through.

Maybe from a user perspective it does have a sense, but from a best practices/REST/anything perspective, this smells to me.

I know we've talked about this a bit already but just wanted to provide some context in case someone stumbles upon this conversation and gets confused.

Remember, this isn't supposed to be a REST API. This is a multi-part request that was implemented as a REST endpoint. Not to say it couldn't be implemented as a REST endpoint (see the proposals discussed earlier
https://pihemr.atlassian.net/wiki/spaces/OB/pages/3075342378/Data+Imports), but we're simply uploading a file and processing the file on the server as we would an XLS or CSV file. Imagine this happening from an HTML form.

The difference between what we're doing here and uploading a form via a GSP is that we usually don't have React interacting with the server via standard HTTP requests. And perhaps we shouldn't ... but until we create a convention around importing data via the API, it's probably safe to go with the HTTP approach.
https://pihemr.atlassian.net/wiki/spaces/OB/pages/3075342378/Data+Imports

So our convention with uploaded data files (i.e. Configuration > Import Data) has been to prevent data from being saved unless all the data is valid. But the case that Manon wants to implement here is slightly different from most of those because the picklist item data we're importing are essentially "edits" to the existing picklist. That's not entirely true since the user will be importing brand new picklist items after clearing the picklist.

The confusion here might be due to the fact that we're exposing this endpoint to a React client where the expectation is to handle a multi-part request and return a JSON response i.e. we have to return the errors as JSON. I don't know if there's a better way for the server to deal with this type of interaction. But since Axios is an HTTP client (not a REST) client it seems reasonable to implement it this way.

Last point. You're correct that this might be handled differently if it was a REST endpoint. But there's precedence for implementing this type of "save what's valid, return errors for what's not" using the 207 Multi-Status status code. I have a bunch of resources saved that describe this when implementing "bulk api" behavior. Here are a few:

Moreover, prove me wrong, but in case of situation when a few items have errors and user would want to fix them, I assume the user wouldn't remove the valid ones (those that are already persisted) from the sheet.

This is a good point. But my understanding is that uploading the file multiple times would not create duplicate picklist items (which I think is your concern). It would just add new lines and update lines that have changed. Furthermore, removing lines from the import file might actually signify that we want to remove them. But that is not a requirement here, as far as I know, so just ignore that point.

In either case, this would be a good test case to implement in an integration test.

  • User clears picklist
  • User uploads a data file with 10 lines, 8 valid, 2 invalid
  • System creates 8 picklist items
  • User fixes 2 invalid lines
  • User uploads a data file with 10 lines
  • System ignores 8 valid lines, creates 2 picklist items

We could even add in a listener on Hibernate events to make sure we're not touching the 8 valid lines on the second import.

render([errorCode: 500, errorMessage: e?.message ?: "An unknown error occurred during import"] as JSON)
return
if (!errors.isEmpty()) {
render([message: "Data imported with errors", errors: errors] as JSON)
Copy link
Collaborator

Choose a reason for hiding this comment

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

😲 I guess this answers my first comment, but I'm in shock if we really want to go this path.
This smells like an anti-pattern to me, that even though there are errors, we would get 200 status and act like everything went well.
P.S. I know this was not your choice to do it this way, but wanted to raise my astonishment.

Copy link
Collaborator

Choose a reason for hiding this comment

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

side comment would be it we want to have those messages translated.

Copy link
Member

Choose a reason for hiding this comment

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

I guess this answers my first comment, but I'm in shock if we really want to go this path.
This smells like an anti-pattern to me, that even though there are errors, we would get 200 status and act like everything went well.
P.S. I know this was not your choice to do it this way, but wanted to raise my astonishment.

@kchelstowski Can you elaborate on this? I feel like I'm missing some context here.

Copy link
Collaborator

Choose a reason for hiding this comment

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

@jmiranda this comment was about importing the template with e.g. 50 valid lines and 50 invalid - I was just surprised that we would proceed even if we were about to save 50 items out of 100.
This is why I was surprised that we first try to import the items and then eventually check if the errors are not empty - in typical scenario we do the opposite.

}

render([data: "Data will be imported successfully"] as JSON)
render([message: "Data imported successfully"] as JSON)
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm wondering why we don't return the imported items in the response

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 is the pattern that we have followed in most of the places where we update some data in the table and folow up with a GET request.

We could talk about it more and have a concrete pattern to follow for future implementations.

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 it's safer to refretch the pickPageItems through the normal mechanism. That way the endpoint here can be free of a potential performance problem if the picklist is large. Let the client fetch deal with the usability problem of a slow load.

errors.push("Item ${itemId} is underpicked, expedted quantity ${pickPageItem.requisitionItem.quantity}, received ${itemsSum}")
}
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

This method basically explains why I hate reviewing and reading those.
I would really love to have this kind of validation happening in a proper validator of a command object. This would reduce the amount of complexed code in one method, or at least make it more readable if we had this type of validation in the constraints of the command object, e.g.

static constraints = {
  id(blank: false)
}

with a properly constructed error message instead of

 if (!params.id) {
   params.errors.push("Requisition item id is required")
}
...

etc..

As a "proof of concept" I would suggest looking at ProductSupplierAttributeCommand/ProductSupplierAttributeBatchCommand where I implemented a bunch of complexed validation along with the updateAttributes method where I handled a similar case to your with appending errors to the array.

P.S. I assume you would not want to change it now, but it would be good to copy this comment to the idea garden ticket you were involved in.

Copy link
Member

Choose a reason for hiding this comment

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

I would really love to have this kind of validation happening in a proper validator of a command

@kchelstowski I agree. In this particular case, the command object would need to be pretty smart to handle the logic. But if we can make it work, let's do it. And as you suggested in the past, this might be a good use case for MapStruct, since we need to convert between different beans/domains. I'm not sure we want to do a proof of concept of all of that in this PR, but it seems like it would be a good proving ground for some future scope.

Copy link
Member

Choose a reason for hiding this comment

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

@kchelstowski Can you create a discovery ticket for this particular refactoring? Just provide a link to the offending code with a few bullets suggesting what we could / should investigate.

sortOrder: pickPageItem.sortOrder
))
}
createOrUpdatePicklistItem(stockMovement, pickPageItems)
Copy link
Collaborator

Choose a reason for hiding this comment

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

I can see that in this method there is the same issue that I've recently fixed. Could you please add in this method the association between requisition and the picklist?
requisition.picklist = picklist

Copy link
Member

Choose a reason for hiding this comment

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

That was only necessary because we were rendering the response with an invalid/unpersisted object so I don't think we're going to see that issue here.

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 just pass in the picklist items, not the pick page items? Or does that method not exist? If it doesn't we should create it and then start to refactor other methods to use the proper API.

FWIW I don't think this method should exist. It doesn't make sense from an API perspective. It feels like we're packing the required data into unnecessary proxy objects.

void createOrUpdatePicklistItem(StockMovement stockMovement, List<PickPageItem> pickPageItems) 

Something like this would be better

void createOrUpdatePicklistItems(StockMovement stockMovement, List<PicklistItem> picklistItems) 

This would be even better

void createOrUpdatePicklistItems(Picklist picklist, List<PicklistItem> picklistItems) 

But we're also in a situation where it probably makes sense to do the persistence in the import method.

void importPicklistItems(List<Map> data, StockMovement stockMovement, 
    List<PickPageItem> pickPageItems) {

    ...
    Picklist picklist = stockMovement.requisition.picklist
    data.each { row ->
        PicklistItem picklistItem = new PicklistItem(row...)
        picklist.addToPicklistItems(picklistItem)
    }
    picklist.save()
}

Copy link
Collaborator

Choose a reason for hiding this comment

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

If we were about to leave it in a separate method, I would suggest calling the .save() on a picklist item (not persisted object) instead of on the picklist and saving the picklistItems via cascade saving.
The picklist is already known to Hibernate, and any of the changes to the picklist (e.g. adding the items to the picklistItems list) would be handled by Hibernate at the transaction commit without having to call .save() explicitly.
The reason I suggest that is that saving the child objects (especially when there are a lot of them) via cascade of the parent (picklist) might cause huge performance issues, difficult to detect - once in another project (Java+Spring) there was a huge performance issue when persisting some complexed stuff due to the cascade saving, and when I changed it to save children separately, the time got reduced from about 4 minutes to 2 seconds....

Copy link
Member

@jmiranda jmiranda May 23, 2024

Choose a reason for hiding this comment

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

That performance issue sounds like maybe something else was happening though. But I take your point which seems to be "let's do things explicitly" vs "relying on Hibernate magic" 🧙‍♀️

But in my mind, the main reasons to use Hibernate is to

  • manage your associations (cascades)
  • avoid costly saving (worse-case: flushing) each individual child object on its own.
  • provide a mechanism for rolling back a transaction

This approach is ignoring the "manage association" benefit of Hibernate, which could lead to performance degredation due to the many flushes that could be happening and might not allow you to rollback the flushed data if an exception happens afterwards.

data.each { 
    PicklistItem picklistItem = new PicklistItem(it)
    picklistItem.picklist = picklist
    picklistItem.save(flush:true)
}

This approach let's Hibernate do its thing (based on cascading rules defined on the domain) and would rollback the picklist item data if there was a validation error in picklist or somewhere else.

data.each { 
    PicklistItem picklistItem = new PicklistItem(it)
    picklist.addToPicklistItems(picklistItem)
}
picklist.save(flush:true)

This is all theoretical though. You seem to distrust Hibernate and I think that's a healthy attitude to have with respect to things that perform magic.

So it would be a good discovery ticket to write this in multiple ways and then perform some integration tests to see which is the right approach. Then we can create a convention around it.

One thing to note in our case is that whenever we save a PicklistItem we're executing the "refresh product availability" code. So that adds a separate wrinkle. Do we want all of those background tasks to happen all at once or when we save the picklist at the end? In the former case we can use the disableRefresh mechanism so it should be a moot point.

Copy link
Collaborator

Choose a reason for hiding this comment

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

@jmiranda as for this one, I might be overcautious - I've relied on the cascade many times before and have not experienced any issues, but I also remember one situation (described above) that was due to the cascade saving.
I wouldn't say I want to do things explicitly, because I love Hibernate, and we should rely on it in most of the cases, e.g. I wouldn't say we should write:

Picklist picklist = Picklist.get(...)
PicklistItem picklistItem = new PicklistItem(...)
picklistItem.picklist = picklist
picklist.picklistItems.add(picklistItem)
picklistItem.save()

instead of:

Picklist picklist = Picklist.get(...)
PicklistItem picklistItem = new PicklistItem(...)
picklist.addToPicklistItems(picklistItem)
picklistItem.save()

because this would be an overkill. The recent bug we experienced around requisition/picklist was in 1:1 association where this have to be handled manually (because Hibernate adds it AFTER the session is closed, this is why it caused issues, whereas the addTo* (e.g. addToPicklistItems adds the association in two objects immediately))

An important note is that we hardly ever need to use flush: true while saving - I'd say the flush: true would be needed if for example constraints of the picklistItem rely on already existing picklistItems saved in the same transaction, to validate them properly.

In case of:

data.each { 
  Picklist picklist = Picklist.get(...)
  PicklistItem picklistItem = new PicklistItem(...)
  picklist.addToPicklistItems(picklistItem)
  picklistItem.save()
}

Hibernate just creates a queue and triggers all the saves at the transaction commit.

So TL;DR would be - I trust in Hibernate magic and we should rely on it in most of the cases, but at the same time we need to know what happens behind the scenes and what might be the edge cases around some of the operations.
As for the performance issues around the cascade - I would skip it for now and check such things in a discovery/spike ticket around Hibernate - including the impact of the batchSize property, etc.

Copy link
Member

Choose a reason for hiding this comment

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

This discussion has been moved to a discovery ticket.
https://pihemr.atlassian.net/browse/OBIG-8

@drodzewicz You can ignore this discussion.

.map((item) => ({ ...item, picklistItems: [], quantityPicked: 0 })),
}),
}));
this.fetchItemsAfterImport();
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm not sure if this additional request after the delete is required here, since this should always be an empty picklist at this point.

Copy link
Collaborator

Choose a reason for hiding this comment

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

As a side note, I also think that the name of that method might be misleading - we are in clearPicklist method and suddenly we trigger a method with "import" in the name.

@drodzewicz drodzewicz requested review from ewaterman and jmiranda May 17, 2024 13:01
Boolean createNew = true

static constraints = {
id nullable: false
Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't mind doing it explicitly, but nullable: false is the default setting, so we could get rid of the id here.

Copy link
Collaborator

Choose a reason for hiding this comment

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

  • you've added the Validateable, and those constraints, but I can't see you eventually validating the command.
    validating is a wrong word, because it's validated while being bound, but I can't see you eventually returning the errors.
    We could skip it for now, since previously there was not such check for the params, but just wanted to emphasize, that currently the errors would be "swallowed".

@drodzewicz drodzewicz force-pushed the OBPIH-6155-ability-to-create-edit-a-pick-via-import-correct-stock-case branch from d2637be to c6a4a21 Compare May 21, 2024 10:25
@drodzewicz drodzewicz requested a review from kchelstowski May 21, 2024 14:39
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.

I'm still working on my review for this one, but have a few comments. These are just thoughts. No actions are required at this time.


"/api/stockMovements/importPickListItems/$id"(parseRequest: true) {
controller = "stockMovementApi"
controller = "picklist"
Copy link
Member

Choose a reason for hiding this comment

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

This is what I was expecting. Thank you.

The point I was trying to make about moving this to a non-API controller was that we don't necessarily need this feature to be exposed by our API.

The request is going to be multipart file so we could just have an importCsv or importXls action in the PicklistController like we do with the StockMovementController.

Note: We've added these endpoints to the PartialReceivingApiController and LocationApiController and I would argue that it was probably the wrong place to add them.

def importCsv(ImportDataCommand command) { 
    // Lookup the picklist
    Picklist picklist = picklistService.getPicklist(params.id)
    // ... and then do sany validation on what we have some far
    //  - picklist exists, otherwise throw exception 
    //  - any request parameters that are needed
    //  - data format of the input file, maybe the separator used
    
    // Or move as much of the validation into the command class validate() method if possible
    if (!command.validate()) {
         throw new ValidateException(command.errors)
    } 
    
    // You could also break the process down into the 
    if (!picklistImportService.validatePicklistCsv(command)) {     
        throw new ValidateException(command.errors)
    }
     
    picklistImportService.importPicklistItems(picklist, command.picklistItems)
}

PicklistImportService

// If you'd prefer to return a list of maps or DTOs that's fine too.
void buildPicklistItems(ImportDataCommand command) { 
    String csv = new String(command.importFile.bytes)
    char separatorChar = CSVUtils.getSeparator(csv, StockMovement.buildCsvRow()?.keySet()?.size())
    def settings = [separatorChar: separatorChar, skipLines: 1]
    Integer sortOrder = 0
    csv.toCsvReader(settings).eachLine { tokens ->
        def picklistItem = buildPicklistItem(tokens)       
        command.data.add(picklistItem)
    }
}

// Not sure if it's cool to do side effects to the command object in a validate method, 
// so if it makes more sense you can return the command object instead of a boolean 
boolean validatePicklistCsv(ImportDataCommand command) {
    // we might need custom logic to check against available items 
    // but i'm not going to be able to put any thought into how to handle that
    
    // validate each of the picklist items
    command.data { row ->  
        if (!row.validate() || row.hasErrors()) {
            command.errors << row.errors
        }
    }
    return command.hasErrors()
}


void importPicklistCsv(Picklist picklist, List<PicklistItem> picklistItems) { 
}

void importPicklistCsv(ImportDataCommand command) {
    Picklist picklist = Picklist.get(command.id)
 
    // Assuming we use DTOs for the CSV -> picklist item object 
    // but I'd also be ok if the buildPicklistItem() method returned
    // domain objects
    if (!command.validate() || !validatePicklistCsv(command)) {
        command.data.each { picklistItemDto ->
            PicklistItem picklistItem = picklistItemDto.toPicklistItem() 
            picklist.addToPicklistImtems(picklistItem)
        }
        // There's probably some further validation and error checking 
        picklist.save()
    }     
}

Copy link
Member

Choose a reason for hiding this comment

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

You do not need to go in this direction, I just wanted to show what I was expecting.

Copy link
Member

@jmiranda jmiranda May 21, 2024

Choose a reason for hiding this comment

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

I moved the conversation about Data Import approaches to Confluence for further discussion.
https://pihemr.atlassian.net/wiki/spaces/OB/pages/3075342378/Data+Imports

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.

More comments some actionable.

errors.push("Item ${itemId} is underpicked, expedted quantity ${pickPageItem.requisitionItem.quantity}, received ${itemsSum}")
}
}
}
Copy link
Member

Choose a reason for hiding this comment

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

I would really love to have this kind of validation happening in a proper validator of a command

@kchelstowski I agree. In this particular case, the command object would need to be pretty smart to handle the logic. But if we can make it work, let's do it. And as you suggested in the past, this might be a good use case for MapStruct, since we need to convert between different beans/domains. I'm not sure we want to do a proof of concept of all of that in this PR, but it seems like it would be a good proving ground for some future scope.

sortOrder: pickPageItem.sortOrder
))
}
createOrUpdatePicklistItem(stockMovement, pickPageItems)
Copy link
Member

Choose a reason for hiding this comment

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

That was only necessary because we were rendering the response with an invalid/unpersisted object so I don't think we're going to see that issue here.

AvailableItem availableItem = pickPageItem.availableItems?.find { item ->
Boolean binLocationMatches = params.binLocation ? item.binLocation?.name == params.binLocation : !item.binLocation
Boolean lotMatches = params.lot == item.inventoryItem?.lotNumber
binLocationMatches && lotMatches
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're seeing this in a few places, let's go ahead and move this into the PickPageItem bean.

AvailableItem availableItem = 
    pickPageItem.getAvailableItem(params.binLocation, params.lotNumber)

PickPageItem

AvailableItem getAvailableItem(Location binLocation, String lotNumber) {
    return availableItems?.find { item ->
                Boolean binLocationMatches = params.binLocation ? item.binLocation?.name == params.binLocation : !item.binLocation
                Boolean lotMatches = params.lot == item.inventoryItem?.lotNumber
                binLocationMatches && lotMatches
}

One question ... is there any way that we could have multiple available items matching on lot number and bin location. My gut says no, but just wanted to make sure.

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 shouln't. I dont think it is possible to create two stock records with same lot and binLocation

}
}

void importPicklistTemplate(List<Map> data, StockMovement stockMovement, List<PickPageItem> pickPageItems) {
Copy link
Member

Choose a reason for hiding this comment

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

This is nit-picky but let's make the following changes:

  • change the method name since we're not importing a template, we're importing picklist items from a template
  • we don't really need StockMovement, so let's try to pass only the args that are needed to the method
  • make the List data the last argument
  • let's change the name of the data argument be more clear
  • consider converting from Map to a Command class, if desired
void importPicklistItems(Picklist picklist, List<PickPageItem> pickPageItems, 
    List<Map> picklistItems) 

Passing pickPageItems here is bothering me but I don't see a good solution at the moment. Maybe if it looked like this (with pickPageItems being included with the stock movement)

void importPicklistItems(StockMovement stockMovement, List<Map> picklistItems)

... then it'd look cleaner. But I don't like the idea of passing a huge command object like StockMovement to a method that only needs access to a few objects under it.

It's possible that we could move all of this into a DTO/Command class hierarchy and that would have only what is necessary to do the work

void importPicklistItems(ImportPicklistCommand command) { 
    ...
}

class ImportPicklistCommand { 
    Picklist picklist
    Location currentLocation
    StockMovement stockMovement
    List<PicklistItemCommand> picklistItems
}

class ImportPicklistItemCommand {
    RequisitionItem requisitionItem
    List<AvailableItem> availableItems
    // Include this if preferred, but the command should only reference the objects it 
    // needed to perform validation and make decisions
    //PickPageItem pickPageItem

    // move validation logic here
}

it.quantity = 0
}
}
pickPageItem.picklistItems.add(new PicklistItem(
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 just bottle up the picklist items in their own list and pass them to the createOrUpdatePicklistItems() method? Even if that means creating a new method?

sortOrder: pickPageItem.sortOrder
))
}
createOrUpdatePicklistItem(stockMovement, pickPageItems)
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 just pass in the picklist items, not the pick page items? Or does that method not exist? If it doesn't we should create it and then start to refactor other methods to use the proper API.

FWIW I don't think this method should exist. It doesn't make sense from an API perspective. It feels like we're packing the required data into unnecessary proxy objects.

void createOrUpdatePicklistItem(StockMovement stockMovement, List<PickPageItem> pickPageItems) 

Something like this would be better

void createOrUpdatePicklistItems(StockMovement stockMovement, List<PicklistItem> picklistItems) 

This would be even better

void createOrUpdatePicklistItems(Picklist picklist, List<PicklistItem> picklistItems) 

But we're also in a situation where it probably makes sense to do the persistence in the import method.

void importPicklistItems(List<Map> data, StockMovement stockMovement, 
    List<PickPageItem> pickPageItems) {

    ...
    Picklist picklist = stockMovement.requisition.picklist
    data.each { row ->
        PicklistItem picklistItem = new PicklistItem(row...)
        picklist.addToPicklistItems(picklistItem)
    }
    picklist.save()
}

* @return
*/
List getPickPageItems(StockMovementItem stockMovementItem) {
List getPickPageItems(StockMovementItem stockMovementItem, Boolean createNewPicklistItems = true) {
Copy link
Member

Choose a reason for hiding this comment

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

I don't feel good about chaning this method in this way.

Can you explain what this is doing and why it's necessary?
Is it possible that we could leave getPickPageItems() alone since it's being used elsewhere, but then transform those into whatever you're trying to do here.

List pickPageItems = getPickPageItems(stockMovementItem)
List pickPageItemsUltra = pickPageItems.collect { transformToSomethingUltra(it) }

Also, I would like to find another name for the createNewPicklistItems argument. It's possible I just don't understand what's happening here and it will become more intuitive once I understand the reason.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The main issue here was that getPickPageItems creates new picklist for items that don't have the picklist.

What I was trying to achieve is to specify to the method that I don't want to create a new picklist for items that don't have it, all I want is to get the existing data.

String csv = new String(importFile.bytes)
List<Map> importedLines = stockMovementService.parsePickCsvTemplateImport(csv)

Boolean supportsOverPick = location.supports(ActivityCode.PICKLIST_OVER_PICK)
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 ALLOW_OVERPICK.

Location location = Location.get(session.warehouse.id)

StockMovement stockMovement = stockMovementService.getStockMovement(params.id)
List<PickPageItem> pickPageItems = stockMovementService.getPickPageItems(params.id, null, null, false)
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 if you pass an additional argument (stepNumber) to getStockMovement(id, stepNumber) you should get the pickPageItems populated, no?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

well... not really
To be honest the method for getStockmovement that receives stepNumber is a little weird
It calls the getRequisitionBasedStockMovement with stepNumber but looking into this function stepNumber isn't being used.
image
I can't really make any sense of git logs on why this argument was added for getRequisitionBasedStockMovement

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 weird. I just remembered that handle the GET stock movement items request via a separate API. So maybe we used to populate the stock movement items (pick page items, etc) from within that method, but needed to pull it out into a separate API endpoint when we added pagination support for the stock movement items API.

🤷

}

def getStockMovementItems(String id, String stepNumber, String max, String offset) {
def getStockMovementItems(String id, Integer stepNumber, Integer max, Integer offset, Boolean createNewPicklist = true) {
Copy link
Member

Choose a reason for hiding this comment

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

I don't remember why we these arguments are Strings, but I would be careful with unintended consequences if you haven't changed all String stepNumber variables to Integer throughout the code (lines 733-739).

With that said, I'm fine with it as long as we make the change throughout or as long as there some sort of implicit coercion happening that allows for the case statements and comparisons to essentially treat the following as true.

assert 1 == "1"
assert "1".equals(1)

If not, we should revert.

If you are going to boy scout this one, you could go one step further and turn these into enums.

OutboundWorkflowState.valueOf(stepNumber) == OutboundWorkflowState.REVISE_ITEMS

and create an enum

enum OutboundWorkflowState { 

    CREATE_HEADER(1),
    ADD_ITEMS(2),
    REVISE_ITEMS(3),
    PICK_ITEMS(4),
    PACK_ITEMS(5),
    SEND_SHIPMENT(6)

    Integer stepNumber

    OutboundWorkflowState(int stepNumber) {
        this.stepNumber = stepNumber
    }

}

Copy link
Collaborator

Choose a reason for hiding this comment

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

I am ok with this change, but please make sure you did not miss anything that is still passing the string

import org.pih.warehouse.requisition.Requisition
import org.springframework.web.multipart.MultipartFile

@Transactional
Copy link
Collaborator

Choose a reason for hiding this comment

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

a side note - in my opinion it is dangerous to move the import method there because of this @Transactional here - if any issue is detected at the transaction commit, it will be swallowed and we won't eventually display it to 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.

good point, I looked at the methods declared in the picklistController and it looks to be safe to remove the @Transaction from the controller. Most of the transactional logic is handled by the transactional service

import grails.validation.Validateable
import org.pih.warehouse.api.PaginationCommand

class StockMovementItemsRequestCommand extends PaginationCommand implements Validateable {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Overall I think this looks good to me, but I am slightly afraid, that StockMovementItemsRequestCommand will be confused with the Requisition domain

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

some potential names to replace the StockMovementItemsRequestCommand

  • StockMovementItemsControllerCommand
  • StockMovementItemsQueryCommand
  • StockMovementItemsParamsCommand
    WDYT?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think Query or Params is the most suiting here 🤔

Copy link
Member

Choose a reason for hiding this comment

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

Important

TL;DR I'm going to give this a bit more thought, but I'm cool with StockMovementItemQueryCommand. Note the pluralization.

TIL: Holy crap, look at this shit ^^^^^^ (had no idea we could do this)

I think there are a few conventions that we could consider here.

When all else fails and we don't have a good idea of a name based on the intent of the command class, then we naming it the same as the method is an ok fallback. I really don't mind this at all, to be honest.

  • GetStockMovementItemsCommand

The problem is when the same command is used across methods (like we do here with the details action) we could start thinking of a better name, one that captures the essence of what the command class is intended to be used for.

In this case it probably makes sense to call it something more expressive.

  • StockMovementItemQueryCommand (recommended)
  • StockMovementItemSearchCommand (meh)
  • StockMovementItemFiltersCommand (we've used this in the past, but it might be too narrow)

Note: I think "Request" and "Params" are telling us what the object is, not what it is for. That might come in handy for a base class or when the intention is multi-faceted (we want to query or create with the same command).

We could also just start with something basic like the name of the name of the underlying class and then refactor as needed.

  • StockMovementItemCommand

This is generic enough to carry individual fields about a stock movement item for creation as well as properties for filtering/querying.

Copy link
Member

Choose a reason for hiding this comment

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

i've added a convention document for this, so we can update that if/when we decide on something
https://pihemr.atlassian.net/wiki/spaces/OB/pages/3075997703/Command+Class

}

def getStockMovementItems(String id, String stepNumber, String max, String offset) {
def getStockMovementItems(String id, Integer stepNumber, Integer max, Integer offset, Boolean createNewPicklist = true) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I am ok with this change, but please make sure you did not miss anything that is still passing the string

enum.ActivityCode.SUBMIT_REQUEST=Submit request
enum.ActivityCode.DYNAMIC_CREATION=Dynamic creation
enum.ActivityCode.AUTOSAVE=Autosave
enum.ActivityCode.PICKLIST_OVER_PICK=Picklist over-pick
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 what about the label?

…ndWorkflowState for steps variable replacement
- add ability to return PickPageItems with existing picklist
- add ability to conditionally allocate missing picklists when returning pick page items
import grails.validation.Validateable
import org.pih.warehouse.api.PaginationCommand

class StockMovementItemsRequestCommand extends PaginationCommand implements Validateable {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think Query or Params is the most suiting here 🤔

- Add StockMovementItemParamsCommand
- rename StockMovementItemsRequestCommand
case "4":
case OutboundWorkflowState.PICK_ITEMS:
if (refresh) {
allocatePicklists(requisition.requisitionItems?.asList())
Copy link
Member

Choose a reason for hiding this comment

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

Yes! Thank you. This is a really good first step. Am I to assume that getPickPageItems() is now just reading? I know I will get there in a minute, but just wanted to ask this explicitly.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

yes, after I separated allocation from building the PickPageItem now getPickPage only reads the value

Copy link
Member

Choose a reason for hiding this comment

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

The one potential "bummer" of fixing the "side effect" of getPickPageItems() is that Manon is expecting that bug to continue to exist i.e. when you refresh or return to the page it allocates items for any lines that don't have picklist items. So there might need to go back and forther a little with her on that to make sure she feels comfortable with what we did.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

not a problem, this behavior still remains I am sending getStockMovementItems with refresh=false flag after importing the file but after refresh and back forward navigation the flag is on true

@@ -269,7 +269,7 @@ class UrlMappings {
}

"/api/stockMovements/importPickListItems/$id"(parseRequest: true) {
Copy link
Member

Choose a reason for hiding this comment

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

Change the URL mapping to

/picklists/importCsv 

or let the default URL mapping configuration handle 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 also need ot indlude stockMvoement id in the mapping so a simple /picklist/importCsv needs to somehow include which stockMovement are we importing it to.

I suggest leaving the mapping as is, since also we would have to deprecate the old mapping as well before moving it to the new URL

Copy link
Member

Choose a reason for hiding this comment

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

I was thinking the ID could go into the multipart request data. But I'm going with that for now.

Just make the URL match the other RPC endpoints on Stock Movement so it's clear what we're doing. And rename the action to "importPicklist" to match the client and what the service method should be called.

/api/stockMovements/$id/importPicklist

def details() {
def stockMovementItem = stockMovementService.getStockMovementItem(params.id, params.stepNumber, true)
/**
* @deprecated As of release 9.2, replaced by {@link #read()} with showDetails query parameter
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 we should discuss this a bit before deprecating as these are different resources.

This one is the "parent" resource that provides just enough information.

/api/stockMovementItems/:id

Whereas the other is a nested resource that provides more detailed information.

/api/stockMovementItems/:id/details

I would prefer to keep the nested resource, rather than require the client to know to provide a request parameter. But you might be right to do it this way.

Regardless, we need to come up with an actual convention here.
https://pihemr.atlassian.net/browse/OBPIH-2649

};
}
apiClient.get(STOCK_MOVEMENT_ITEM_BY_ID(this.state.attr.itemId), {
params: { stepNumber: 4, showDetails: true },
Copy link
Member

Choose a reason for hiding this comment

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

Note

This provides more context for the earlier comment about the @deprecated comment.

So the tricky thing with the "details" nested resource is that it was a compromise we made because we didn't have a good solution to the "multiple representations of a resource" issue described in this ticket (https://pihemr.atlassian.net/browse/OBPIH-2649) and we didn't have time to design all of the resources we needed to implement for the stock movement workflow ahead of time.

Ignoring the step number layer to this, I didn't want to include the picklist items in the main Stock Movement Items GET endpoint

/api/stockMovementItems/:id

by default, because then we'd be adding that data every time we made this request.

The "details" nested resource was a way to say "every once in awhile i want you to give me a different view of the stock movement item that includes more details". So it's a completely different representation of the Stock Movement Item. And while it's probably ok to pass that as a request parameter I wanted it to stand out to developers as a separate resource because the JSON response is completely different. I am not sure

In addition, we can't use a picklist items nested resource here because that would only return the picklist items associated with the stock movement items. For example, let's assume we could

/api/picklists/:id/items?stockMovementItemId=:stockMovementItemId

or

/api/picklistItems?stockMovementItemId=:stockMovementItemId

Instead what we need is a combination of the two "the lines representing the requested items, along with any allocated items (picklist items associated with them". And to this day I don't really have a good idea about how to solve that problem. And that's why we got step numbers in the Stock Movement API. Ugh.

So while the "details" nested resource might not be the right way to provide a different representation of a resource, it's the current convention and shouldn't be deprecated until we have a better solution. But I'd strongly advocate against a single endpoint returning multiple representations based on request parameters like we're doing with the stepNumber and showDetails for the "read" endpoint along with all of the other requests that handle "stepNumber".

Using the Content Negotiation (Accepts request header) might be an ... acceptable approach since it's a very explicit thing that the developer needs to do.

 Accepts: application/vnd.org.pih.warehouse.api.stockmovement+json;version=1
 Accepts: application/vnd.org.pih.warehouse.api.stockmovement.details+json;version=2”

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.

This is looking really good, thanks @drodzewicz.

static constraints = {
stepNumber nullable: true
refresh nullable: true
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

those two commands seem to be almost the same (the above could extend this one)

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 noticed that and also thought about extending StockMovementItemsParamsCommand from StockMovementItemParamsCommand but I didn't want two commands to depend on each other that much.
I'd rather stick with two separate DTOs without extending them from each other since both of them call different service methods.

Copy link
Member

Choose a reason for hiding this comment

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

Yeah this is one of the reasons I don't like Command/DTOs as we can easily get into a situation where we create multiple Commands/DTOs for similar things. Until we can come up with a proper convention, I'd prefer have the controller action handle the request parameters itself in order to avoid the Indistinguishable Command Class per Controller Action ™️ anti-pattern

Important

Convention Proposal:
We shouldn't need to create a separate command class for every action. We should either be able to consolidate command classes for similar actions or deal with request parameters within the controller.

But instead of continuing to litigate that here, let's move the discussion to the Naming Convention > Command Class document I created a few days ago.
https://pihemr.atlassian.net/wiki/spaces/OB/pages/3075997703/Command+Class

Once we have a satisfactory convention we can go back and apply that to our command class hierarchy (this is also very much related to the API resource refactoring that needs to happen as that will likely lead to a bunch of new command/DTOs as well).

Map<String, List> groupedItems = picklistItems.groupBy { it.id }

picklistItems?.each { ImportPickCommand data ->
data.validate()
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm actually struggling with understanding what this validate()'s role is here - we just call it "in the air", but I don't see you relying on it anywhere below, since still you proceed with some kind of manual validation?

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 need to call validate before calling other rejectValue() because if I first do my manual validation with rejectValue and then call validate it will clear all of the existing errors and only generate ones which failed dues to constraints specified in the Command file.

Later in the import method I check if command record has any errors and skip the import of that row.

if (!data.id) {
return
}
PickPageItem pickPageItem = pickPageItems.find { it.requisitionItem?.id == data.id }
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 you are already overwhelmed with this PR, so treat it just as a comment, but imho those manual validations could also be handled in the command's validator.
After this find, you could just set the pickPageItem to the ImportPickCommand and then call .validate() on the 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 agree with @kchelstowski, but we can make this change as part of a future refactoring.

I think the ImportPickCommand could actually be holding a lot more information, including the relevant PickPageItem / AvailableItems or at least the quantityOnHand / quantityAvailable.

@drodzewicz drodzewicz force-pushed the OBPIH-6155-ability-to-create-edit-a-pick-via-import-correct-stock-case branch from b0e97af to 2c7915c Compare May 24, 2024 11:10
- rever deprecation of details endpoint
- Move OutboundWorkflowState enum to src/main/groovy/warehouse/org/api
- Added OutboundWorkflowState const to react
- Do not refresh picklist when opening PickModal
@drodzewicz drodzewicz force-pushed the OBPIH-6155-ability-to-create-edit-a-pick-via-import-correct-stock-case branch from 2c7915c to 10ccb65 Compare May 24, 2024 11:21
String code
String name
String lot
String expiration
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 be more specific here and use the built-in data binding if possible. Any reason not to?

String lotNumber
Date expirationDate
Location binLocation

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 could you elaborate on "use the built-in data binding".
If you mean to use bindData() then it will be quite hard since I am doing the binding to the ImportPickCommand in the Service method where bindData is not available since it is on a controller method.

String lot
String expiration
String binLocation
String quantity
Copy link
Member

Choose a reason for hiding this comment

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

Any reason not to use the type?

Integer quantity

})
expiration(nullable: true)
binLocation(nullable: true)
quantity(nullable: false, blank: false)
Copy link
Member

Choose a reason for hiding this comment

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

We should probably have a constraint for quantity > 0?

Copy link
Member

Choose a reason for hiding this comment

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

We could also potentially add a quantityOnHand, quantityAvailable on the command object, have that set as part of the processing and then include those validation constraints here as well. This is probably where @kchelstowski's comment about the ugly validation code comes into play. We don't need to do that now, but just wanted to share how I would consider doing that to move validation logic to the command class.

@@ -0,0 +1,30 @@
package org.pih.warehouse.inventory
Copy link
Member

Choose a reason for hiding this comment

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

Also I just noticed that this was put in the grails-app/services directory. I don't mind it being in inventory since other stock movement classes are in there? But could this go into the grails-app/controllers directory instead?

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.

waiting for a review and hopefully a merge for import ticket. P.S. I would suggest to go forward with the PR if in your opinion everything looks somewhat good, if there are small change requests like a name of a variable or something that does not impact the logic of import and getPickPageItems method then you can write them up in the PR and I will address them later. I believe we should merge this importer asap because it's still needs to be QA'ed for potential issues that might (hopefully not) come up

@drodzewicz I'm going to merge as-is but please work through the requested changes when you get a chance.

@jmiranda jmiranda merged commit 8cea5cb into develop May 25, 2024
@jmiranda jmiranda deleted the OBPIH-6155-ability-to-create-edit-a-pick-via-import-correct-stock-case branch May 25, 2024 05:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants