Skip to content

OBPIH-6893 Create the batch start count API#5018

Merged
awalkowiak merged 3 commits intodevelopfrom
OBPIH-6893
Feb 3, 2025
Merged

OBPIH-6893 Create the batch start count API#5018
awalkowiak merged 3 commits intodevelopfrom
OBPIH-6893

Conversation

@kchelstowski
Copy link
Collaborator

✨ Description of Change

A concise summary of what is being changed. Please provide enough context for reviewers to be able to understand the change and why it is necessary. If the issue/ticket already provides enough information, you can put "See ticket" as the description.

Link to GitHub issue or Jira ticket:

Description:


📷 Screenshots & Recordings (optional)

If this PR contains a UI change, consider adding one or more screenshots here or link to a screen recording to help reviewers visualize the change. Otherwise, you can remove this section.

@kchelstowski kchelstowski self-assigned this Jan 28, 2025
@github-actions github-actions bot added domain: backend Changes or discussions relating to the backend server flag: schema change Hilights a pull request that contains a change to the database schema domain: l10n Changes or discussions relating to localization & Internationalization labels Jan 28, 2025
@kchelstowski
Copy link
Collaborator Author

A few comments from my side may come up to explain some decisions or to point out things I am not quite sure/would like to get a confirmation.

@codecov
Copy link

codecov bot commented Jan 28, 2025

Codecov Report

Attention: Patch coverage is 9.67742% with 112 lines in your changes missing coverage. Please review.

Project coverage is 7.79%. Comparing base (08ea84c) to head (4d118c0).
Report is 214 commits behind head on develop.

Files with missing lines Patch % Lines
...g/pih/warehouse/inventory/CycleCountService.groovy 0.00% 42 Missing ⚠️
...main/org/pih/warehouse/inventory/CycleCount.groovy 10.71% 25 Missing ⚠️
...g/pih/warehouse/api/CycleCountApiController.groovy 0.00% 13 Missing ⚠️
...house/inventory/CycleCountStartBatchCommand.groovy 0.00% 10 Missing ⚠️
.../warehouse/inventory/CycleCountStartCommand.groovy 0.00% 8 Missing ⚠️
...y/org/pih/warehouse/inventory/CycleCountDto.groovy 0.00% 6 Missing ⚠️
.../org/pih/warehouse/inventory/CycleCountItem.groovy 62.50% 3 Missing ⚠️
...ehouse/inventory/ProductAvailabilityService.groovy 0.00% 2 Missing ⚠️
...rg/pih/warehouse/inventory/CycleCountStatus.groovy 0.00% 2 Missing ⚠️
...g/pih/warehouse/inventory/CycleCountItemDto.groovy 0.00% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             develop   #5018      +/-   ##
============================================
+ Coverage       7.72%   7.79%   +0.07%     
- Complexity       854     868      +14     
============================================
  Files            610     620      +10     
  Lines          42589   42744     +155     
  Branches       10346   10370      +24     
============================================
+ Hits            3292    3334      +42     
- Misses         38810   38914     +104     
- Partials         487     496       +9     

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

action = [POST: "createRequests"]
}

"/api/facilities/$facility/cycle-counts/start/batch" {
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 I tried to follow your last suggestion that those params should be also bound to a command, but suddenly they aren't 🤔 they are only accessible via params.

Copy link
Member

@jmiranda jmiranda Jan 30, 2025

Choose a reason for hiding this comment

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

For what it's worth, it did work for me. I'll try to pull up the PR that I was working on to see if I did anything differently. Off the top of my head, the only difference I can remember is that the UrlMapping I used had braces around facility (I remember because Evan commented on it). I doubt that has any bearing since $facility and ${facility} should be equivalent, but worth a shot I guess.

"/api/facilities/${facility}/cycle-counts/start/batch"

Copy link
Member

@jmiranda jmiranda Jan 30, 2025

Choose a reason for hiding this comment

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

Ok, I just tested this again and the command data binding works for GET requests, but not for POSTs. Interesting and annoying.

Edit: Here's a PR with a simple data binding for the major verbs
https://github.com/openboxes/openboxes/pull/5021/files

and a short video showing the behavior
https://app.screencastify.com/v2/manage/videos/1jekeZ1yqCH0okuRP7Ia

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 thanks for testing it out. Yeah it's annoying. Btw - the link with the video doesn't work for me.

Copy link
Member

Choose a reason for hiding this comment

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

Sorry had to change the permissions on it. Should be accessible now.

errors.addError(error)
}
}
throw new ValidationException("Invalid cycle count request", errors)
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 see the typo)

if (cycleCountItems.every { it.status in [CycleCountItemStatus.REVIEWED, CycleCountItemStatus.APPROVED, CycleCountItemStatus.REJECTED] }) {
return CycleCountStatus.COMPLETED
}
return null
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I followed this:
Screenshot from 2025-01-28 10-08-02

Copy link
Member

Choose a reason for hiding this comment

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

I'll let Artur review these first. I haven't spent much time thinking about what they should look like

updatedBy: AuthService.currentUser,
// FIXME: I don't know what default values for custom and draft should be
custom: false,
draft: true,
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

as mentioned in the FIXME - I am not sure what default values for those should be, as they weren't determined anywhere in the docs.

Copy link
Member

Choose a reason for hiding this comment

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

custom: false for all rows initially. It should only be true for rows that the client adds manually (the last row in this image). We need to distinguish these rows because for those the user is allowed to specify bin location and lot number.

image

I think draft: false for all rows initially makes sense (because we don't even have a draft yet, just items that are ready to count), though maybe draft: true is fine. The main use for it is in computing CycleCount and CycleCountItem status. If the user adds a count for all rows but doesn't submit it yet (so draft: true for all rows) we want to make sure that we keep the status as COUNTING (and don't advance it to COUNTED yet).

Thinking about it more now though, maybe the fact that we haven't called submit count yet will already guarantee that status==COUNTING and so we might not even need the draft flag at all.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

tech huddle: remove the draft property

Copy link
Collaborator

Choose a reason for hiding this comment

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

Should it be removed (i still see it)?

if (cycleCount.status in [CycleCountStatus.TO_REVIEW, CycleCountStatus.CANCELED]) {
throw new IllegalArgumentException("Cycle count is already in review or canceled")
}
if (cycleCount.status in [CycleCountStatus.COUNTING, CycleCountStatus.COUNTED]) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

in the doc it was meant for "If the cycle count is “resolving” or “counted” - since we don't have RESOLVING status I assumed this is equivalent to the COUNTING? 🤔

Copy link
Member

@ewaterman ewaterman Jan 28, 2025

Choose a reason for hiding this comment

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

we need to be able to distinguish between:

  1. a cycle count exists and the current count has already been started (status in [COUNTING]) so just fetch it
  2. a cycle count exists and the previous count was completed (status in [COUNTED]) so we're starting a recount

(I'm not totally sure where INVESTIGATING fits into those cases but it's in one of them. If INVESTIGATING means a recount is already started, then it's case 1. If it means a recount is required but hasn't started, then it's case 2.)

Case 2 can be left for TODO for now because it's recounting but we need to properly deal with 1.

There's two was to handle case 1 in this API:
A) Throw an error. You're not allowed to start a count that has already been started.
B) Simply return the cycle count and its items as they are because the count has already started / been initialized

If we pick A, then the frontend will need to do two things when starting a batch count:

  • Call a GET list API to fetch all cycle counts in the batch that already started
  • Call this POST start count API on all requests that haven't been started yet

The design was done assuming solution B because it would be simpler for the frontend. This means this API needs to be able to gracefully handle both 1 and 2 cases.

Let me know if that makes sense. And if you feel like solution A makes more sense (which after seeing how complex this flow already is, I'm wishing was what I picked originally) we can talk about switching to that but unless you say otherwise I'll assume you want to stick with the original plan (aka B).

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

could you elaborate on that?

a cycle count exists and the current count has already been started (status in [COUNTING]) so just fetch it

I kinda don't get what the current count means at this point. If we update a cycle count, it means that you've just fetched it, and it's our "existing cycle count" and "current count" at once.

Copy link
Member

@ewaterman ewaterman Jan 29, 2025

Choose a reason for hiding this comment

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

  • You're on the TO COUNT tab and start a count on request_id == 1.
  • Frontend calls the start count API and the backend creates and returns cycle_count_id == 1.
  • You log out and come back to the TO COUNT tab and click start count again on the same request (1)
  • Frontend calls the start count API (again). The fetches the count associated with that request, finds it, sees that it's active, and so just returns it (so the start count API is essentially doing the get on behalf of the frontend)

an alternative flow (that I wish I designed instead) would be:

  • You're on the TO COUNT tab and start a count on request_id == 1.
  • Frontend calls the start count API and the backend creates and returns cycle_count_id == 1.
  • You log out and come back to the TO COUNT tab and click start count again on the same request (1)
  • Frontend sees that the count is already in progress (based on the returned status) and so calls GET counts with id=1 (so the frontend is responsible for knowing what requests to start, and what counts to get)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

tech huddle: COUNTING -> INVESTIGATING

if (cycleCount.status in [CycleCountStatus.COUNTING, CycleCountStatus.COUNTED]) {
throw new UnsupportedOperationException("Support will be added later")
}
if (cycleCount.status in [CycleCountStatus.COUNTING, CycleCountStatus.COUNTED, CycleCountStatus.INVESTIGATING, CycleCountStatus.TO_REVIEW]) {
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 was meant to work for cycle count "in progress", so I took this from:
Screenshot from 2025-01-28 10-11-05

cycleCountItems.sort { it.countIndex }
if (cycleCountItems?.first()?.countIndex != request.countIndex) {
throw new IllegalArgumentException("Count index can't be higher than the highest count index of the items")
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

"If the given countIndex != max(count_index of the Items) , error"

(Will need to test it deeper though, as all my data contained only one item, hence I am yet sure if I should look for first or last item after sorting)

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 sorts low to high so it'd be last? I'm not sure. You could also just use Collections.max() if you don't want to bother sorting the whole list of items


List<CycleCountItemBasicDto> cycleCountItems

static CycleCountDto createFromCycleCountItems(List<CycleCountItem> items) {
Copy link
Collaborator Author

@kchelstowski kchelstowski Jan 28, 2025

Choose a reason for hiding this comment

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

I gotta say it was quite tricky to work with the items without having the bidirectional relationship between Cycle count and Cycle count items, but I remember the reasons for promoting the unidirectional many-to-one relationship.


static CycleCountDto createFromCycleCountItems(List<CycleCountItem> items) {
// Cycle count items have the same cycle count associated, so we can just look at first item in order to get the cycle count properties
CycleCountItem item = items?.first()
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 one of the "tricky" parts I mentioned above. If I had an access to the list of the items from the cycle count domain, I wouldn't have to do that.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

eventually we can check if every item has the same cycle count (in case in the future this method would be used for "mixed" cycle count items (with different cycle count associated) and throw an exception.
Now, it can't happen, so let me know your thoughts.

Copy link
Member

Choose a reason for hiding this comment

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

here's the docs I made forever ago talking about relationships.

I think it could actually be fine here because:

  • The number of CycleCountItems for each CycleCount should be minimal (ie <100)
  • It's not an ever growing list of items. We do a count, maybe do a recount, then we're done. The cycle count is eventually resolved and we stop adding items to it
  • Typically when fetching a CycleCount we already fetch all of its Items. The biggest concern of the bidirectional was that adding a new CycleCountItem would require fetching and validating against all existing ones, but since we should already have all Items loaded, it's not really a problem

So maybe it's fine in this case to allow for bidirectional.

Or if you still don't want to do bidirectional, then I think this is fine. You could also pass in the CycleCount as a method arg here instead of extracting it from the Items. That feels more natural to me since you're building a DTO by giving it all the parts it needs.

As an aside, if we're not doing bidirectional but still want to be able to get all items for a cycle count, we could just have the CycleCount domain implement a getCycleCountItems() method that does a CycleCountItem.findAllByCycleCount(cycleCount) call.

List<AvailableItem> itemsToSave = determineCycleCountItemsToSave(facility, request.cycleCountRequest.product)
newCycleCount.save()
// 1:1 association between cycle count and cycle count request
request.cycleCountRequest.cycleCount = newCycleCount
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

since the cycle count request's status is a persistent value, I'm wondering if we should change it right here, if so - should it be REQUESTED or IN_PROGRESS?

Copy link
Member

Choose a reason for hiding this comment

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

IN_PROGRESS makes sense to me here but I'll defer to Artur

Copy link
Collaborator

Choose a reason for hiding this comment

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

iirc this should be derived, so we can disregard saving it (at least for now)

countIndex(min: 0, max: 1, validator: { Integer countIndex, CycleCountStartCommand obj ->
CycleCount cycleCount = obj.cycleCountRequest.cycleCount
if (!cycleCount && countIndex != 0) {
return ['mustEqualZero']
Copy link
Member

Choose a reason for hiding this comment

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

note that this will change when we introduce recount since starting a recount will use the same start count API. https://pihemr.atlassian.net/wiki/spaces/OB/pages/3363340321/TO+RESOLVE+page#Backend


class CycleCountStartCommand implements Validateable {

CycleCountRequest cycleCountRequest
Copy link
Member

Choose a reason for hiding this comment

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

does the client need to pass the whole request here or just request id and Grails binds the request object to it?

Copy link
Collaborator Author

@kchelstowski kchelstowski Jan 28, 2025

Choose a reason for hiding this comment

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

we had that discussion recently regarding the Product.
Both options are possible, so to provide it like:

{
  "cycleCountRequest": "UUID"
}

or

{
  "cycleCountRequest": {
    "id": "UUID"
  }
}


Location facility

Date dateLastRefreshed
Copy link
Member

Choose a reason for hiding this comment

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

now that I'm seeing this, I wonder if the dateLastRefreshed field would be better off in the CycleCountItem because that's where we're doing the actual QoH refresh. If we're in a recount and do a refresh, we only change QoH for the CycleCountItem with countIndex==1 (countIndex==0 stays unchanged). So it could be useful to have that date info at the item level to be able to clearly see why QoH changed between counts.

I don't know if you've had a chance to think about this yet. (For context, this is the API that will use the field)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

tech huddle: this could be added to the cycle count item later

comment(nullable: true)
assignee(nullable: true)
quantityCounted(nullable: true)
discrepancyCause(nullable: true)
Copy link
Member

Choose a reason for hiding this comment

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

Does Grails default to nullable: false for all fields? I always thought it was the other way around

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@@ -0,0 +1,9 @@
package org.pih.warehouse.inventory

enum DiscrepancyCause {
Copy link
Member

Choose a reason for hiding this comment

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

I'm realizing that we never had a group discussion on what values should exist here (I wrote these ones myself). Lets discuss it with Manon.

We'll also need to localize each of these.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

DiscrepancyCause -> DiscrepancyReasonCode


import org.pih.warehouse.product.Product

class CycleCountItemBasicDto {
Copy link
Member

Choose a reason for hiding this comment

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

Is the idea here that you don't need all fields so "basic" means a simplified DTO and for the other APIs we'll have a more complete DTO that returns all fields?

I generally like to be specific with the naming of DTOs since we could get to a place where one API needs one format, and another needs another format, and so on, and so if we have a DTO named "basic", it becomes unclear what API it should and should not be used for.

That said, I'm fine with it for this use case because I can't think of a better name.

But also maybe it's fine to always just return the full CycleCountItemDto with all its fields

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I followed the response pattern designed here.
Let me know if this makes sense or we would want to return the whole cycle count item.

Copy link
Member

@ewaterman ewaterman Jan 29, 2025

Choose a reason for hiding this comment

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

hmm yeah I don't know if there was a good reason why I left out fields from the responses. Probably I just included the important ones. Sorry for not being clear about it. I'll leave it for you to decide but I think we'd be fine with just one CycleCountItemDto for all our responses here.

EDIT: ah now I remember. I think I left the other fields out in the example response because it's implied that they'd be null. It looks like I missed a couple fields though. Probably because the domain fields were changing a lot at time time. Apologies.


static CycleCountDto createFromCycleCountItems(List<CycleCountItem> items) {
// Cycle count items have the same cycle count associated, so we can just look at first item in order to get the cycle count properties
CycleCountItem item = items?.first()
Copy link
Member

Choose a reason for hiding this comment

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

here's the docs I made forever ago talking about relationships.

I think it could actually be fine here because:

  • The number of CycleCountItems for each CycleCount should be minimal (ie <100)
  • It's not an ever growing list of items. We do a count, maybe do a recount, then we're done. The cycle count is eventually resolved and we stop adding items to it
  • Typically when fetching a CycleCount we already fetch all of its Items. The biggest concern of the bidirectional was that adding a new CycleCountItem would require fetching and validating against all existing ones, but since we should already have all Items loaded, it's not really a problem

So maybe it's fine in this case to allow for bidirectional.

Or if you still don't want to do bidirectional, then I think this is fine. You could also pass in the CycleCount as a method arg here instead of extracting it from the Items. That feels more natural to me since you're building a DTO by giving it all the parts it needs.

As an aside, if we're not doing bidirectional but still want to be able to get all items for a cycle count, we could just have the CycleCount domain implement a getCycleCountItems() method that does a CycleCountItem.findAllByCycleCount(cycleCount) call.

if (cycleCountItems.every { it.status in [CycleCountItemStatus.REVIEWED, CycleCountItemStatus.APPROVED, CycleCountItemStatus.REJECTED] }) {
return CycleCountStatus.COMPLETED
}
return null
Copy link
Member

Choose a reason for hiding this comment

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

I'll let Artur review these first. I haven't spent much time thinking about what they should look like

updatedBy: AuthService.currentUser,
// FIXME: I don't know what default values for custom and draft should be
custom: false,
draft: true,
Copy link
Member

Choose a reason for hiding this comment

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

custom: false for all rows initially. It should only be true for rows that the client adds manually (the last row in this image). We need to distinguish these rows because for those the user is allowed to specify bin location and lot number.

image

I think draft: false for all rows initially makes sense (because we don't even have a draft yet, just items that are ready to count), though maybe draft: true is fine. The main use for it is in computing CycleCount and CycleCountItem status. If the user adds a count for all rows but doesn't submit it yet (so draft: true for all rows) we want to make sure that we keep the status as COUNTING (and don't advance it to COUNTED yet).

Thinking about it more now though, maybe the fact that we haven't called submit count yet will already guarantee that status==COUNTING and so we might not even need the draft flag at all.

List<AvailableItem> itemsToSave = determineCycleCountItemsToSave(facility, request.cycleCountRequest.product)
newCycleCount.save()
// 1:1 association between cycle count and cycle count request
request.cycleCountRequest.cycleCount = newCycleCount
Copy link
Member

Choose a reason for hiding this comment

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

IN_PROGRESS makes sense to me here but I'll defer to Artur

if (!cycleCountItem.validate()) {
throw new ValidationException("Invalid cycle count item", cycleCountItem.errors)
}
cycleCountItem.save()
Copy link
Member

Choose a reason for hiding this comment

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

what happens if itemsToSave has > 1 element and we fail on the 2nd+ element? Will the 1st still be saved? That could put us in a weird state right? Maybe better would be to build them all, validate them all, then save them all

Copy link
Member

Choose a reason for hiding this comment

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

or maybe because it's all wrapped in the same @Transactional it's fine because it won't get committed.

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, since it's being persisted in the same transaction, an error in any of the items would cause the rollback of whole operation

List<AvailableItem> availableItems =
productAvailabilityService.getAvailableItems(facility, [product.id])
CycleCount previousCycleCount =
CycleCountRequest.findAllByProductAndFacility(product, facility).cycleCount.sort { it.lastUpdated }.first()
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 think lastUpdated is what you want here. That field should only ever be used for audit purposes. If someone goes back and edits an old cycle count for whatever reason, that field will get updated and so we'll fetch it here, even if there's a more recent cycle count.

I think what we want is a dateCompleted/Submitted (or whatever) field in CycleCount that gets set once the cycle count is submitted and is never updated again.

Copy link
Member

Choose a reason for hiding this comment

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

Also, you're fetching all counts on the product then in the code throwing out all returned rows exept the most recent. Is there a way to move that into the query itself? Something like a SELECT * WHERE MAX(dateCompleted). That would save us from having to select more than we need so it'd be less work on the db with fewer rows locked

Copy link
Collaborator Author

@kchelstowski kchelstowski Jan 29, 2025

Choose a reason for hiding this comment

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

Also, you're fetching all counts on the product then in the code throwing out all returned rows exept the most recent. Is there a way to move that into the query itself? Something like a SELECT * WHERE MAX(dateCompleted). That would save us from having to select more than we need so it'd be less work on the db with fewer rows locked

It's probably doable via the createCriteria, but for now I focused on delivering the easiest version possible, but I could try to improve the query

Copy link
Member

Choose a reason for hiding this comment

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

that's fine with me (though I encourage you to spend a bit of time trying to get it to work). If you're not going to fix it here though can you add a TODO comment for it? It could be a relatively important optimization because as we have more and more cycle counts on a product this will fetch more and more data, and so will only get worse over time.

CycleCountRequest.findAllByProductAndFacility(product, facility).cycleCount.sort { it.lastUpdated }.first()
List<CycleCountItem> previousCycleCountItems = CycleCountItem.findAllByCycleCount(previousCycleCount)
List<AvailableItem> filteredItems = availableItems.findAll { AvailableItem availableItem ->
(availableItem.quantityOnHand > 0) || (previousCycleCountItems.find { it.inventoryItem.id == availableItem.inventoryItem.id })}
Copy link
Member

Choose a reason for hiding this comment

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

can quantityOnHand be < 0? If so, we definitely want to count negative quantity items. Maybe an == 0 check is safer here.

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 think the condition is more complicated here. We want to exclude items where in the previous count QoH==quantity counted==0.

So we want include all rows that:

  • have a non-zero QoH OR
  • have a zero QoH but in the previous count QoH != quantity counted OR quantity counted != 0

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

filter out all bin locations + inventory items where QoH==0 AND (the previous cycle count did not include a count on that bin locations + inventory items OR the previous count also had QoH==0).

The quote above is taken from the tech design. Is it still applicable or the version you've just written is up to date?

Copy link
Member

Choose a reason for hiding this comment

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

yes it's still applicable. In the second half of your OR, you're including items that were in a previous count, but you only want to include them if quantity counted != 0.

In my comment above I thought it'd be good to further refine this to also include them if quantity counted == 0 but QoH at the time was != 0. The idea being that even though there was zero last time, there was a discrepancy so we should double check out.

Lets talk about it with Manon in the tech huddle though to confirm the exact expected behaviour. Maybe since we're already auto-filling a 0 for empty bins it's fine to not filter them out.

if (cycleCount.status in [CycleCountStatus.TO_REVIEW, CycleCountStatus.CANCELED]) {
throw new IllegalArgumentException("Cycle count is already in review or canceled")
}
if (cycleCount.status in [CycleCountStatus.COUNTING, CycleCountStatus.COUNTED]) {
Copy link
Member

@ewaterman ewaterman Jan 28, 2025

Choose a reason for hiding this comment

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

we need to be able to distinguish between:

  1. a cycle count exists and the current count has already been started (status in [COUNTING]) so just fetch it
  2. a cycle count exists and the previous count was completed (status in [COUNTED]) so we're starting a recount

(I'm not totally sure where INVESTIGATING fits into those cases but it's in one of them. If INVESTIGATING means a recount is already started, then it's case 1. If it means a recount is required but hasn't started, then it's case 2.)

Case 2 can be left for TODO for now because it's recounting but we need to properly deal with 1.

There's two was to handle case 1 in this API:
A) Throw an error. You're not allowed to start a count that has already been started.
B) Simply return the cycle count and its items as they are because the count has already started / been initialized

If we pick A, then the frontend will need to do two things when starting a batch count:

  • Call a GET list API to fetch all cycle counts in the batch that already started
  • Call this POST start count API on all requests that haven't been started yet

The design was done assuming solution B because it would be simpler for the frontend. This means this API needs to be able to gracefully handle both 1 and 2 cases.

Let me know if that makes sense. And if you feel like solution A makes more sense (which after seeing how complex this flow already is, I'm wishing was what I picked originally) we can talk about switching to that but unless you say otherwise I'll assume you want to stick with the original plan (aka B).

cycleCountItems.sort { it.countIndex }
if (cycleCountItems?.first()?.countIndex != request.countIndex) {
throw new IllegalArgumentException("Count index can't be higher than the highest count index of the items")
}
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 sorts low to high so it'd be last? I'm not sure. You could also just use Collections.max() if you don't want to bother sorting the whole list of items

return CycleCountStatus.REVIEWED
}
if (cycleCountItems.every { it.status == CycleCountItemStatus.TO_REVIEW }) {
return CycleCountStatus.TO_REVIEW
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

change to "READY_TO_REVIEW"

Copy link
Collaborator

Choose a reason for hiding this comment

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

Should this be changed?

@awalkowiak awalkowiak merged commit 1c8e15c into develop Feb 3, 2025
9 checks passed
@awalkowiak awalkowiak deleted the OBPIH-6893 branch February 3, 2025 14:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

domain: backend Changes or discussions relating to the backend server domain: l10n Changes or discussions relating to localization & Internationalization flag: schema change Hilights a pull request that contains a change to the database schema

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants