Skip to content

OBPIH-7010 add reload button in recount flow to refresh QoH#5092

Merged
ewaterman merged 8 commits intodevelopfrom
ft/OBPIH-7010-cycle-count-qoh-refresh
Mar 11, 2025
Merged

OBPIH-7010 add reload button in recount flow to refresh QoH#5092
ewaterman merged 8 commits intodevelopfrom
ft/OBPIH-7010-cycle-count-qoh-refresh

Conversation

@ewaterman
Copy link
Member

@ewaterman ewaterman commented Mar 5, 2025

✨ Description of Change

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

Description: Added the "Reload" button to the recount page that does the following:

  • if QoH for the [product + bin location + lot number] has changed, update it in the count item
  • if QoH for the [product + bin location + lot number] is now 0, remove the count item (unless it's a custom row, in which case we set QoH for the item to 0)
  • if a new [product + bin location + lot number] has been added, create a new count item for it

📷 Screenshots & Recordings (optional)

Screenshot from 2025-03-07 10-35-20

https://jam.dev/c/d85f7766-e873-471d-9aba-1539224eac2a

The video is a little confusing but prior to the recording I did the following:

  • created the "TESTING" lot with QoH == 36
  • added 500+ quantity to the existing "No lot OMronM2" lot

you can see that after the refresh, lot "TESTING" was added to the count and "No lot OMronM2" had a much larger count difference. (There are duplicate rows for some reason but this appears to be unrelated to my change so I didn't fix.)

@ewaterman ewaterman self-assigned this Mar 5, 2025
@github-actions github-actions bot added type: feature A new piece of functionality for the app domain: frontend Changes or discussions relating to the frontend UI domain: backend Changes or discussions relating to the backend server domain: l10n Changes or discussions relating to localization & Internationalization labels Mar 5, 2025
@codecov
Copy link

codecov bot commented Mar 5, 2025

Codecov Report

Attention: Patch coverage is 49.29577% with 72 lines in your changes missing coverage. Please review.

Project coverage is 8.16%. Comparing base (0286fa3) to head (34ab195).
Report is 175 commits behind head on develop.

Files with missing lines Patch % Lines
...g/pih/warehouse/inventory/CycleCountService.groovy 0.00% 28 Missing ⚠️
...entory/CycleCountProductAvailabilityService.groovy 73.80% 10 Missing and 12 partials ⚠️
.../warehouse/inventory/CycleCountStartCommand.groovy 0.00% 8 Missing ⚠️
...main/org/pih/warehouse/inventory/CycleCount.groovy 28.57% 2 Missing and 3 partials ⚠️
...g/pih/warehouse/api/CycleCountApiController.groovy 0.00% 4 Missing ⚠️
...ouse/inventory/CycleCountTransactionService.groovy 40.00% 1 Missing and 2 partials ⚠️
...use/inventory/CycleCountStartRecountCommand.groovy 0.00% 1 Missing ⚠️
...rg/pih/warehouse/inventory/CycleCountStatus.groovy 50.00% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             develop   #5092      +/-   ##
============================================
+ Coverage       8.00%   8.16%   +0.16%     
- Complexity       915     945      +30     
============================================
  Files            635     636       +1     
  Lines          42995   43086      +91     
  Branches       10451   10477      +26     
============================================
+ Hits            3440    3518      +78     
+ Misses         39031   39021      -10     
- Partials         524     547      +23     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

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

* */
* Batch start multiple cycle counts.
*/
List<CycleCountDto> startCycleCount(CycleCountStartBatchCommand command) {
Copy link
Member Author

Choose a reason for hiding this comment

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

I did a bit of cleanup on the start cycle count API while I was reworking the product availability flow. Mainly I removed some outdated validation (the updateCycleCount method is not needed anymore) and moved other validation logic to the command object instead like we do for recount.

CycleCount cycleCount = command.cycleCount

if (command.refreshQuantityOnHand) {
boolean hasQuantityChanged = cycleCountProductAvailabilityService.refreshProductAvailability(cycleCount)
Copy link
Member Author

Choose a reason for hiding this comment

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

we now do the QoH refresh outside of the cycleCountItems loop below. We can't just loop the items of the count because there might be new items created that the count is not aware of.

Copy link
Member

Choose a reason for hiding this comment

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

suggestion: It would be cool to separate the "detect changes" from the "apply changes" in case we want show the user what's about to happen. Detect changes could return a list of changes that could be displayed to the user.

Copy link
Member Author

Choose a reason for hiding this comment

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

yeah I was actually considering doing something like this originally but it got kinda complex (I needed three lists, delete, create, update). I'll give it another think through again though

// While the final QoH of the product for each [bin location + lot number] will always be the same value
// as was counted, if the QoH in the cycle count items changes at this step, the amount of quantity
// adjustment/change might be different than what was displayed to the user during the count.
cycleCountProductAvailabilityService.refreshProductAvailability(cycleCount)
Copy link
Member Author

Choose a reason for hiding this comment

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

same here. We now refresh QoH outside of the loop on cycle count items because there may be new items that the count is not aware of

Copy link
Member

Choose a reason for hiding this comment

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

While the final QoH of the product for each [bin location + lot number] will always be the same value as was counted

I'm struggling to understand this comment. Is this just saying the final quantity counted (i.e. the quantity entered into the cycle count item) will be the same as the value counted in the physical world? Or is "final QoH" referring to the QoH computed and stored in product availability?

I think I get the concept you're trying to lay out, though. Correct me if I'm wrong.

  • The user start the count, QoH (product availability) is copied to cycle count item (X)
  • The user counts the cycle count item in the physical world (puts the value on paper) (Y)
  • The user enters the quantity counted (Y) into the cycle count item
  • The user submits the count for review (X and Y are saved to cycle count item)
  • The system computes the quantity variance (Z = X - Y)
  • The system displays the quantity variance (Z) to the user
  • The user refreshes the cycle count which might update the quantity variance (Z2 = X2 - Y)
  • The system displays this new quantity variance to the user but the user doesn't not necessarily see how the value changed (before/after refresh)

Copy link
Member Author

Choose a reason for hiding this comment

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

yeah essentially. The value of Y (aka quantity counted) remains unchanged from a refresh, so while quantity variance and the resulting adjustment amount may change, the actual quantity that we end up with will still be Y.

I'll update the comment to clarify

}

SortedSet cycleCountItems
SortedSet<CycleCountItem> cycleCountItems
Copy link
Collaborator

Choose a reason for hiding this comment

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

Have you made sure the items are still sorted properly? When I was helping Sebastian with that, initially I had this Set typed, but if I remember correctly the sorting was not working. In the docs they do not set type for SortedSet, I know this should be working with the type, but maybe it's Grails' bug that it doesn't.
Please make sure the sorting is still working when adding the type.

Copy link
Member Author

Choose a reason for hiding this comment

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

oh interesting. It looks like it's working fine for me (it's sorting by expiration date):

image

private boolean updateItem(CycleCount cycleCount, Set<CycleCountItem> currentCountItems,
AvailableItem availableItem, Location facility, int currentCountIndex,
User assigneeForNewItems, Date dateCountedForNewItems,
CycleCountItemStatus statusForNewItems) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I would suggest creating a class for this amount of arguments being passed to a method. I think we once agreed that 3-4 arguments should be the maximum.

Copy link
Member Author

Choose a reason for hiding this comment

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

hmm yeah I can do that. I'll try to think a bit about it though to see if there's a way I can simplify things. The only reason I split this method out was to make the flow a bit cleaner so if it's still confusing maybe that's not helpful

Copy link
Member

Choose a reason for hiding this comment

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

Just create a command class something like UpdateOrCreateCycleCountItemCommand.

Copy link
Member

Choose a reason for hiding this comment

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

You could also potentially break this into multiple methods (find or create vs detect changes vs update).

Copy link
Member

Choose a reason for hiding this comment

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

The method that detect changes could also return data (or a command) to tell you what operation needs to be executed: UpdateCycleCountItemCommand, DeleteCycleCountItemCommand, etc.

boolean itemHasChanged = updateItem(cycleCount, cycleCountItems, availableItem, facility, currentCountIndex,
assigneeForNewItems, dateCountedForNewItems, statusForNewItems)

itemsHaveChanged = itemsHaveChanged || itemHasChanged
Copy link
Collaborator

Choose a reason for hiding this comment

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

I would add a comment explaining what is happening here. I had to think a longer time what it is doing.
I assume if the first item is changed, the flag is set to true, and then we don't even have to check further items in terms of itemHasChanged.

// The refresh is based on the current product availability in all [bin location + lot] combinations for all
// the products of the count. We do this because new items might have been created since the count started.
List<AvailableItem> availableItems = getAvailableItems(facility, cycleCount.products)
for (AvailableItem availableItem : availableItems) {
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 thought): what if an available item was deleted for a particular inventoryItem + bin? Is it even possible? 🤔
if it is, then shouldn't we actually loop through cycleCountItems and find matching availableItem for an item?
I'm thinking about a situation where you have 5 cycleCountItems and suddenly only 4 matching availableItems - for this scenario, we wouldn't even "touch" one of the cycleCountItems for which the stock might not even exist.
Sounds complicated so let me know if you need a further clarification.

Copy link
Member Author

Choose a reason for hiding this comment

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

I thought we decided in a previous tech huddle that that case wasn't possible, or at least isn't realistic. I can double check with Manon about it though

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 remember us confirming that it can definitely happen but I could be wrong. One example that comes to mind is that if you perform a Record Stock, this will remove inventory items from the product availability table if the items aren't included in the PRODUCT_INVENTORY transaction.

Copy link
Member

@jmiranda jmiranda Mar 7, 2025

Choose a reason for hiding this comment

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

For v1, I think we need to implement a few simple (known) scenarios (quantities are changed) but be very defensive to protect against scenarios that we haven't thought of or are more complicated. I think the easiest way to do that would be to check whether the inventory items in product availability match exactly with the cycle count items (except for quantity). If they don't match, we should fail-fast and suggest cancelation of the cycle count until we have a chance to deal with the particular scenario that is missing or train users on how to get the transaction data right. We're going to have scenarios we haven't thought about so this will at least prevent us from causing more accuracy issues.

Note that the cycle count is an investigative / measurement process. The fact that we are also trying to correct inventory is, in my opinion, a secondary concern. So my preference would still be to create adjustments off what we knew at the beginning of the count rather than what we're doing here. In fact, this could be a location-based feature flag. I see two potential feature flags

  1. whether to allow refresh at all (hide the button)
  2. whether to fail or proceed when detecting changes

Note

I just want to point out again that I don't like that we're doing this. For v1, I think the easiest solution would have been to create a policy that users only do cycle counting when other transactions are not occurring. The onus should be on the user / facility to ensure that no transactions occur at the time of a cycle count i.e. do cycle counts at the end of the day after all receipts and outbound orders have been processed.

We could have added a check to see if the cycle count items and product availability align at the end of the count and fail-fast if they don't. That way, if everything aligns, all we need to do is create adjustments based on a product availability snapshot taken at the time the count was started. The fact that transactions would occur during a cycle count is a symptom of bad practices (not doing the counts after other activities that create transactions, counts taking too long, etc). If we have that policy and process in place, we could add this feature in v2 and then relax our constraints as we go. That would have been the safest approach.

Copy link
Member

Choose a reason for hiding this comment

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

@kchelstowski Thanks for bringing that up.

Copy link
Member Author

@ewaterman ewaterman Mar 7, 2025

Choose a reason for hiding this comment

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

if you perform a Record Stock, this will remove inventory items from the product availability table if the items aren't included in the PRODUCT_INVENTORY transaction.

wouldn't that just set QoH to 0, not delete the actual row? Anyways, I'll implement the case regardless. It's not too complex of a scenario to code up.

We could have added a check to see if the cycle count items and product availability align at the end of the count and fail-fast if they don't.

We do support that via the failOnOutdatedQuantity flag. We just also support refreshing it via the refreshQuantityOnHand flag. We let the client decide what behaviour they want. It's easy to change by simply specifying refreshQuantityOnHand=false.

The fact that transactions would occur during a cycle count is a symptom of bad practices

Most sites that are doing inventory counts now don't create transactions during the count (according to conversations Manon had). They pause everything. That said, I do think having the ability to recover from transactions happening isn't a bad thing. If we're following good practices, we won't ever need to refresh, but not being able to at all can result in scenarios where entire bins get zeroed out.

But again this is an edge case scenario. Normally we won't actually need to refresh at all. So I get your point. The thing we should fix is the practice, but assuming this flow works, I don't see any harm in having it

Copy link
Member

Choose a reason for hiding this comment

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

That said, I do think having the ability to recover from transactions happening isn't a bad thing.

I completely agree. I think this feature is going to be game-changing.

Apologies, this got longer than I was hoping. We can discuss on a call if you see any merit to continuing this conversation.


My main point is that while we might think we're covering our bases by implementing these three generic cases:

  • same: there are the same number of cycle count items as product availability items
  • more: there are more cycle count items than product availability items
  • less: there are less cycle count items than product availability items

... it's possible that we might come to each of these three cases in more ways than we understand and it might also be possible that we might need to handle each case differently based on the specific use case that caused the state we're in.

And I'm not saying that we're not covering all of these use cases properly at the moment. I'm saying that I don't know for sure because it's complex. There are a lot of in-betweens that make this more complicated than "there are transactions happening while we're cycle counting".

For example, when a user is picking an order, the quantity picked might be anywhere in the warehouse (even if the system thinks it's in the bin location). It could be in the bin location, in-transit to the packing location, at the packing station, in a staging area, loaded onto a truck, etc. The system will always think the quantity being picked is still in the bin location so the inventory item will still be in product_availability (at the bin location) until it is shipped. If the quantity picked is anywhere else but the bin location then we might encounter a double debit (quantity picked, quantity adjusted) when the cycle count is completed.

The opposite is true when someone is putting an inventory item away. They could physically move the stock to the putaway location but they haven't walked back to the computer to enter the putaway into the system yet. So the counter would likely have double count stock.

I know we've thought a lot about these scenarios. So again, I'm not saying we're not handling these cases properly. I think the idea is to deal with these issues in the resolution process i.e. go off and investigate so that we understand where the stock might have gone. But that assumes that we can actually figure out where all of the inventory items went and explain discrepancies well enough to resolve them all without making a mistake.

But mistakes happen. Someone could see that a count variance can be explained by an outbound pick (because the quantity variance matches the quantity to pick) so they resolve the issue as "item was picked and moved to staging area" when it hadn't been picked yet. After the count is done another user goes and picks the order and we find ourselves in a double debit situation.

These are the easy cases so we'll probably handle them fine. But I'm concerned about scenarios that we haven't thought about or encountered yet.

TL;DR: Thinking that we can homogenously handle each case (e.g "there are the same/less/more cycle count items than product availability items") feels dangerous to me.

Copy link
Member Author

@ewaterman ewaterman Mar 11, 2025

Choose a reason for hiding this comment

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

that's a fair point. The system only matches the inventory items as they are in the app, so any user error that causes OB to not reflect reality will still result in inaccuracies. But that's essentially outside of our control. We only know what OB knows, and so all we can do is stay in sync with that. Using your example, if a warehouse has a process that moves stock before it has been picked in OB (and don't document anywhere physically that this has happened), then someone does a count without knowledge of what the first person did, then the first person puts the pick in OB causing a double deduction, well that's probably a bad process that needs fixing and retraining.

The way I see it, any solution will always be stuck having to work in tandem with training users in communication and in best practices of using the app (and processing stock movements in general).

I get that we want to be flexible to however they want to use the app, but some practices, like the example I said above, fundamentally introduce confusion into the system. And that could be fixed in a number of ways. Maybe they require anything that is picked to always immediately get put in OB, maybe they have a physical ledger of pending picks that they can reference when counting to make sure they don't double deduct the stock if they find it missing.

Another example could be someone input a stock transfer in OB but didn't move the physical stock yet. Another user refreshes the count, sees a new bin has been added, goes to count it, finds nothing so puts 0, then we've wiped out the whole bin. But that's the same indication to me of a process failure.

I do get your point, but I see these types of inaccuracies as an indication of a bad practice and failure of communication. And that failure can be resolved via whatever way they see fit, whether its via keeping a more accurate stock in OB, or adding communication processes in the warehouse itself.

Either way, if someone misunderstands a situation and inputs inaccurate data, the hope is that they later realize what happened, and a new cycle count can be initialized to fix mistakes. Or if they don't realize, then at least it will get caught the next time a cycle count on the product is automatically scheduled.

Copy link
Member

Choose a reason for hiding this comment

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

Maybe they require anything that is picked to always immediately get put in OB,

That is exactly what needs to happen. We built OB with a very low volume warehouse use case in mind. But now that we're doing cycle counts and allowing transactions to occur during the cycle count, that means we need to be more fine-grained with the transactions. Whether stock is being picked into a "tote" and taken to an outbound packing location, being loaded onto a truck, or is on a forklift heading from the receiving bay to a putaway location ... each of these micro transactions should be tracked in OB.

This is why I've been advocating for handheld workflows. These activities (receipt, putaway, pick, pack, load, etc) are all internal transactions that should be recorded in the system to have precise details about where stock exists.

This may never happen with PIH, but for high volume warehouses this is going to be essential.

Copy link
Member

Choose a reason for hiding this comment

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

maybe they have a physical ledger of pending picks that they can reference when counting to make sure they don't double deduct the stock if they find it missing.

Yes, a lot of these locations have physical bin cards that can be used to understand the flow. It just gets more complicated to unravel and understand the edge cases as more actors (picker, counter) and systems (OB, paper bin card) get introduced into a scenario.

}
}
return CycleCountDto.toDto(cycleCount)
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

what happened with that method? I can't find where it has been moved or why it has been removed

Copy link
Member Author

Choose a reason for hiding this comment

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

this is part of the refactoring I did.

The first and second check got moved to the CycleCountStartCommand. There, if !cycleCount.status.isCounting() (which makes only REQUESTED and COUNTING valid), we error.

The third check isn't actually valid anymore since we split up start count and start recount flows and start count no longer accepts the countIndex (we just hard code countIndex == 0 for start count).

if (!cycleCount) {
// When first starting a count, the cycle count object won't exist yet, so this is valid.
return true
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

you don't need to check for valid cases, by default this would return true, so this check is unnecessary
Not a change request but if you were to remove this check, remember to add null safe operators or handle it somehow else below

Copy link
Member Author

Choose a reason for hiding this comment

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

it's so the next check doesn't fail if the cycleCount is null. I could have made the next check if (cycleCount && !cycleCount.status.isCounting()) but I figured it'd be cleaner this way and would make it easier to add more valiation in the future if we needed

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.

Looks good. I would just try to be defensive about scenarios we aren't explicitly handling. For example, the one Kacper brought up but more generally when cycle count items and product availability don't match on more than just quantity). Once we determine how to handle these scenarios we can add them in, but until then failing fast would be recommended.

action = [POST: "createCycleCountItem"]
}

"/api/facilities/$facility/cycle-counts/$cycleCountId/items/refresh" {
Copy link
Member

Choose a reason for hiding this comment

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

future suggestion: Can we just make this an operation on the cycle count rather than the items subresource?

Also, this isn't a big deal (doesn't need to be done), but I have been wanting to adopt a convention around these RPC-like endpoints to distinguish them from the API resources.

I like the way google handles them.

Any chance we could

"/api/facilities/$facility/cycle-counts/$cycleCountId:refresh" {
    controller = "cycleCountApi"
    action = [POST: "refreshCycleCountItems"]
}

We could even do it more generically

"/api/facilities/$facility/cycle-counts/$cycleCountId:$batchOperation" {
    controller = "cycleCountApi"
    action = [POST: batchOperation]
}

And the URL would look something like this.

/api/facilities/$facility/cycle-counts/$cycleCountId:refreshCycleCountItems

Copy link
Member Author

Choose a reason for hiding this comment

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

hmm an interesting idea. I don't know if I like having a generic operation handler API (how would we handle different request payloads? It seems like in the best case we'd end up with a huge switch statement) but I do think we should revisit our uri structure and the colon structure is intriguing.

I made it /items because it's an operation on the items, not the count (and we could have a /items/$cycleCountItemId/refresh API that refreshes just a single row/item if we really wanted) but I suppose we don't need to make that distinction when refreshing all the items of a count.

Copy link
Member

Choose a reason for hiding this comment

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

Sorry, I didn't make that more explicit.

The batchOperation was meant to show that we could use a variable for the action handler as opposed to defining multiple URL mappings that looks the same.

"/api/facilities/$facility/cycle-counts/$cycleCountId:$batchOperation" {
    controller = "cycleCountApi"
    action = [POST: batchOperation]
}

Therefore, we would implement the actions we want to support as separate methods.

def refreshCycleCount(RefreshCycleCountCommand) { }
def doThatThing(DoThatThingCommand command) { }
def doThatOtherThing(DoThatOtherThingCommand) { }  

The main design element would just be changing from a RPC endpoint (which looks like a REST resource but isn't).

/api/facilities/$facility/cycle-counts/$cycleCountId/refresh

to an action-oriented with a different naming convention

/api/facilities/$facility/cycle-counts/$cycleCountId:refresh

Functionally equivalent, but I really want to avoid RPC endpoints. So we either need to come up with a subresource where a refresh could be implemented using REST or we support these non-REST actions using a non-REST naming convention.

Copy link
Member Author

Choose a reason for hiding this comment

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

hm interesting. So essentially we have a /resource:action structure where we're making a distinction between the resource we're acting on and the action we're performing. Yeah I do like that.

I'm going to leave it as /$cycleCountId/refresh for now until we can think it through more thoroughly, but I do think a structure like $cycleCountId:refresh that is more explicit about what is the resource and what is the action could be quite useful.

// The refresh is based on the current product availability in all [bin location + lot] combinations for all
// the products of the count. We do this because new items might have been created since the count started.
List<AvailableItem> availableItems = getAvailableItems(facility, cycleCount.products)
for (AvailableItem availableItem : availableItems) {
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 remember us confirming that it can definitely happen but I could be wrong. One example that comes to mind is that if you perform a Record Stock, this will remove inventory items from the product availability table if the items aren't included in the PRODUCT_INVENTORY transaction.

// The refresh is based on the current product availability in all [bin location + lot] combinations for all
// the products of the count. We do this because new items might have been created since the count started.
List<AvailableItem> availableItems = getAvailableItems(facility, cycleCount.products)
for (AvailableItem availableItem : availableItems) {
Copy link
Member

@jmiranda jmiranda Mar 7, 2025

Choose a reason for hiding this comment

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

For v1, I think we need to implement a few simple (known) scenarios (quantities are changed) but be very defensive to protect against scenarios that we haven't thought of or are more complicated. I think the easiest way to do that would be to check whether the inventory items in product availability match exactly with the cycle count items (except for quantity). If they don't match, we should fail-fast and suggest cancelation of the cycle count until we have a chance to deal with the particular scenario that is missing or train users on how to get the transaction data right. We're going to have scenarios we haven't thought about so this will at least prevent us from causing more accuracy issues.

Note that the cycle count is an investigative / measurement process. The fact that we are also trying to correct inventory is, in my opinion, a secondary concern. So my preference would still be to create adjustments off what we knew at the beginning of the count rather than what we're doing here. In fact, this could be a location-based feature flag. I see two potential feature flags

  1. whether to allow refresh at all (hide the button)
  2. whether to fail or proceed when detecting changes

Note

I just want to point out again that I don't like that we're doing this. For v1, I think the easiest solution would have been to create a policy that users only do cycle counting when other transactions are not occurring. The onus should be on the user / facility to ensure that no transactions occur at the time of a cycle count i.e. do cycle counts at the end of the day after all receipts and outbound orders have been processed.

We could have added a check to see if the cycle count items and product availability align at the end of the count and fail-fast if they don't. That way, if everything aligns, all we need to do is create adjustments based on a product availability snapshot taken at the time the count was started. The fact that transactions would occur during a cycle count is a symptom of bad practices (not doing the counts after other activities that create transactions, counts taking too long, etc). If we have that policy and process in place, we could add this feature in v2 and then relax our constraints as we go. That would have been the safest approach.

import org.pih.warehouse.inventory.CycleCountSubmitRecountCommand
import org.pih.warehouse.inventory.CycleCountUpdateItemCommand


Copy link
Member

Choose a reason for hiding this comment

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

joke: But I loved this line, why did you get rid of it?

Copy link
Member Author

Choose a reason for hiding this comment

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

anxiety/perfectionist brain to the rescue!

// The refresh is based on the current product availability in all [bin location + lot] combinations for all
// the products of the count. We do this because new items might have been created since the count started.
List<AvailableItem> availableItems = getAvailableItems(facility, cycleCount.products)
for (AvailableItem availableItem : availableItems) {
Copy link
Member

Choose a reason for hiding this comment

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

@kchelstowski Thanks for bringing that up.

private boolean updateItem(CycleCount cycleCount, Set<CycleCountItem> currentCountItems,
AvailableItem availableItem, Location facility, int currentCountIndex,
User assigneeForNewItems, Date dateCountedForNewItems,
CycleCountItemStatus statusForNewItems) {
Copy link
Member

Choose a reason for hiding this comment

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

The method that detect changes could also return data (or a command) to tell you what operation needs to be executed: UpdateCycleCountItemCommand, DeleteCycleCountItemCommand, etc.

CycleCount cycleCount = command.cycleCount

if (command.refreshQuantityOnHand) {
boolean hasQuantityChanged = cycleCountProductAvailabilityService.refreshProductAvailability(cycleCount)
Copy link
Member

Choose a reason for hiding this comment

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

suggestion: It would be cool to separate the "detect changes" from the "apply changes" in case we want show the user what's about to happen. Detect changes could return a list of changes that could be displayed to the user.


if (command.refreshQuantityOnHand) {
boolean hasQuantityChanged = cycleCountProductAvailabilityService.refreshProductAvailability(cycleCount)
if (hasQuantityChanged && command.failOnOutdatedQuantity) {
Copy link
Member

Choose a reason for hiding this comment

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

Right now, the failOn is probably up to the frontend developer but it would be good to wire this into a feature flag that can be used to set this value on the frontend. i.e. instead of hard-coding this in React, we just pull from a the feature flag from the AppContext API.

Copy link
Member Author

Choose a reason for hiding this comment

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

agreed (though I'm leaving it as is for now). I have a lot of thoughts on how to handle frontend settings that I want to put in a discovery. Hopefully I can get that going soon.

// We need to compare the quantity counted against the most up to date QoH in product availability.
// However, if the cycle count items already have an up to date QoH (which will be the case if
// refreshQuantityOnHand == true when submitting the count), we don't need to re-compute it.
if (!itemQuantityOnHandIsUpToDate) {
Copy link
Member

Choose a reason for hiding this comment

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

Shouldn't we recompute whether this is true instead of relying on an argument? Or is that what's happening within the caller when this is invoked?

Copy link
Member Author

Choose a reason for hiding this comment

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

yeah it's happening in the caller. This will be true if command.refreshQuantityOnHand == true when submitting a count. We trust that the caller already did the refresh (cycleCountService.submitCount calls cycleCountProductAvailabilityService.refreshProductAvailability(cycleCount)) so that we can avoid doing it again here. It's a performance saving thing.

There isn't really another way of determining if this is true since to do so would require fetching product availability again (which is what we're trying to avoid).

// While the final QoH of the product for each [bin location + lot number] will always be the same value
// as was counted, if the QoH in the cycle count items changes at this step, the amount of quantity
// adjustment/change might be different than what was displayed to the user during the count.
cycleCountProductAvailabilityService.refreshProductAvailability(cycleCount)
Copy link
Member

Choose a reason for hiding this comment

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

While the final QoH of the product for each [bin location + lot number] will always be the same value as was counted

I'm struggling to understand this comment. Is this just saying the final quantity counted (i.e. the quantity entered into the cycle count item) will be the same as the value counted in the physical world? Or is "final QoH" referring to the QoH computed and stored in product availability?

I think I get the concept you're trying to lay out, though. Correct me if I'm wrong.

  • The user start the count, QoH (product availability) is copied to cycle count item (X)
  • The user counts the cycle count item in the physical world (puts the value on paper) (Y)
  • The user enters the quantity counted (Y) into the cycle count item
  • The user submits the count for review (X and Y are saved to cycle count item)
  • The system computes the quantity variance (Z = X - Y)
  • The system displays the quantity variance (Z) to the user
  • The user refreshes the cycle count which might update the quantity variance (Z2 = X2 - Y)
  • The system displays this new quantity variance to the user but the user doesn't not necessarily see how the value changed (before/after refresh)

@ewaterman
Copy link
Member Author

I refactored the CycleCountProductAvailabilityService. The logic is the same but now it uses a CycleCountRefreshState context object as per suggestions in review comments. I also have the method returning CycleCountItemsForRefresh which contains lists of the items that were created, updated, and deleted, as a part of the QoH refresh. I think it looks cleaner now.

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.

Overall I think thisis ok. It's hard to review and tell if this won't break anything due to all the refactoring, it would be best to test it out locally first (but I won't have time to do it this time). I would have some nitpicky comments, but I am going to spare them for now, since we are tight on the schedule.

@ewaterman
Copy link
Member Author

@awalkowiak I definitely should have split the refactor into its own PR.

The main thing here is the CycleCountProductAvailabilityService, which I have unit tests for so I feel pretty comfortable that it is working. Before I merge this I'm going to retest the other flows that I refactored as thoroughly as I can to make sure nothing new is broken. I'll only merge end of day if I can confirm that

@ewaterman ewaterman merged commit f94fb86 into develop Mar 11, 2025
9 checks passed
@ewaterman ewaterman deleted the ft/OBPIH-7010-cycle-count-qoh-refresh branch March 11, 2025 21:32
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 type: feature A new piece of functionality for the app

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants