Skip to content

OBPIH-6903 Implement cycle count request API (create)#4991

Merged
awalkowiak merged 7 commits intodevelopfrom
OBPIH-6903
Jan 9, 2025
Merged

OBPIH-6903 Implement cycle count request API (create)#4991
awalkowiak merged 7 commits intodevelopfrom
OBPIH-6903

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 2, 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 2, 2025
@codecov
Copy link

codecov bot commented Jan 2, 2025

Codecov Report

Attention: Patch coverage is 11.42857% with 62 lines in your changes missing coverage. Please review.

Project coverage is 7.68%. Comparing base (08b3608) to head (42b54fa).
Report is 188 commits behind head on develop.

Files with missing lines Patch % Lines
...g/pih/warehouse/api/CycleCountApiController.groovy 0.00% 13 Missing ⚠️
...g/pih/warehouse/inventory/CycleCountService.groovy 0.00% 12 Missing ⚠️
...use/inventory/CycleCountRequestBatchCommand.groovy 0.00% 12 Missing ⚠️
...g/pih/warehouse/inventory/CycleCountRequest.groovy 28.57% 10 Missing ⚠️
...arehouse/inventory/CycleCountRequestCommand.groovy 0.00% 10 Missing ⚠️
...warehouse/inventory/CycleCountRequestStatus.groovy 0.00% 2 Missing ⚠️
...h/warehouse/inventory/CycleCountRequestType.groovy 0.00% 2 Missing ⚠️
grails-app/init/org/pih/warehouse/BootStrap.groovy 50.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff            @@
##             develop   #4991   +/-   ##
=========================================
  Coverage       7.67%   7.68%           
+ Complexity       834     833    -1     
=========================================
  Files            605     610    +5     
  Lines          42451   42521   +70     
  Branches       10308   10315    +7     
=========================================
+ Hits            3260    3268    +8     
- Misses         38711   38773   +62     
  Partials         480     480           

☔ 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 = [GET: "getCandidates"]
}

"/api/facilities/$facilityId/cycle-counts/requests" {
Copy link
Member

Choose a reason for hiding this comment

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

the original design has: /api/facilities/{facility_id}/cycle-counts/request/batch (though now that I look at it, maybe .../requests/batch is better)

I did it that way to be more explicit about the fact that this is a batch API call. IMO /cycle-counts/requests would be used for simple CRUD APIs on a single CycleCountRequest. But maybe that's just personal preference.

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'm fine with adding /batch.

}
}

List<CycleCountRequest> createRequest(CycleCountRequestCommand 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'd do createRequests since this is creating multiple

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

yeah, forgot to change it, I had the same feeling at some point, thanks for pointing that out

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

enum CycleCountStatus {
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 since he did the bulk of the work on statuses but do we need both CREATED and REQUESTED?

It's probably worthwhile adding docstrings to each of the statuses to describe what they mean. Some are obvious, but still probably helpful for future us to understand.

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 consulted it with Artur before, and we agreed that for now we can go the easiest way possible and elaborate on it either in the code review or refactor it later, since this wouldn't be a difficult change.


Product product

CycleCountStatus status
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 assuming this would be a CycleCountRequestStatus enum. Won't a CycleCountRequest and CycleCount have separate statuses?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, if so, I can rename it to CycleCountRequestStatus.

import org.pih.warehouse.core.Location
import org.pih.warehouse.product.Product

class CycleCountRequestCommand implements Validateable {
Copy link
Member

@ewaterman ewaterman Jan 2, 2025

Choose a reason for hiding this comment

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

okay now I understand why you didn't make it a batch API call. I thought about doing it this way originally since it avoids needing to define the blindCount for each product, but I ended up designing it with the batch since it was clearer and more flexible IMO.

With the batch it'd be making a POST on a List<CycleCountRequestCommand>:

/api/facilities/{facility_id}/cycle-counts/requests/batch

[
  {
    requestType: "MANUAL_REQUEST",
    blindCount: true,
    productId: "10001",
  },
  ...
],

the main advantage of this is that we can easily make non-batched calls by removing the /batch from the URI and using the same CycleCountRequestCommand object:

/api/facilities/{facility_id}/cycle-counts/requests

{
  requestType: "MANUAL_REQUEST",
  blindCount: true,
  productId: "10001",
}

which IMO makes the most intuitive sense structurally. (It also allows us to optionally configure the "blindCount" value per product, which we likely don't need to do but it's nice to have the option.) Let me know your thoughts

Copy link
Member

@ewaterman ewaterman Jan 2, 2025

Choose a reason for hiding this comment

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

PS, in my original docs I had the batch payload as:

{
  requests: [
    {
      requestType: "MANUAL_REQUEST",
      blindCount: true,
      productId: "10001",
    },
    ...
  ],
}

so it'd have a separate CycleCountRequestBatchCommand object. This way, we can still have the constraints block that you have for verifying things like not having the same product twice in the "requests" 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.

Actually since this is product based, and the facility, requestType, blindCount remains the same for all products, the way I built it is:

{
    "facility": "8a8a9e9665c4f59d0165c54ec6b10027",
    "products": [
        "10004",
        "10005",
    ],
    "blindCount": true
}

and then I loop through the command.products.
I thought it was redundant to build it like you've just shown, because the only "variable" here is the productId.

Aside:
This was also easier from the technical point of view, because if I had to do it in the way you described, we would need two command classes to make it work, so:

class Command1 {
  List<Command2> command
}

and

class Command2 {
  CycleCountRequestType requestType
  Boolean blindCount
  Product product
}

because we can't bind a command with list type, so something like this:

def createRequests(List<Command2> command)

wouldn't work

Copy link
Member

@ewaterman ewaterman Jan 2, 2025

Choose a reason for hiding this comment

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

yeah that's the tradeoff. I do still prefer having the XCommand and XBatchCommand (which wraps a List) objects since IMO that gives the clearest representation and isolation of the data and the operations we're performing, but I definitely get the appeal of wanting the simpler API that avoids redundant field duplication. Ultimately I'm fine with it the way you have it designed, it just gives us a bit less flexibility in the future if we need to change the API requirements.

(Also, annoying that it doesn't allow you to bind a list, though I believe that's not a recommended practice anyway...)

<column name="product_id" type="CHAR(38)">
<constraints nullable="true" />
</column>
<column name="status" type="CHAR(38)">
Copy link
Member

Choose a reason for hiding this comment

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

should this be VARCHAR since it's based on an enum?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

you are right, I've just copied the type from the id. Will fix it.

<column name="blind_count" type="BIT">
<constraints nullable="false" />
</column>
<column name="request_type" type="CHAR(38)">
Copy link
Member

Choose a reason for hiding this comment

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

same here. Should be VARCHAR?

products(nullable: true, validator: { List<Product> products, CycleCountRequestCommand obj ->
String productCode = null
boolean duplicateExists = products.any { Product product ->
// FIXME: Most probably, this will have to be modified not to include completed cycle counts
Copy link
Member

@ewaterman ewaterman Jan 2, 2025

Choose a reason for hiding this comment

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

yeah you'd want to change the findBy to something like a countByProductAndFacilityAndStatus (where status not in [COMPLETED, CANCELED])

Probably also worth adding a convenience method in the enum to return a list of all "closed" (or all "open") statuses.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

yeah, I left that FIXME to remember, as this will be doable after CycleCount domain is created and working.

}
// Flush is needed to know what has already been persisted inside this loop. Without flush, we could save two CCR for the same product and facility.
// This won't be doable via UI, but could be possible via API.
cycleCountRequest.save(flush: true)
Copy link
Member

Choose a reason for hiding this comment

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

can't you prevent this in the constraints block of the CycleCountRequestCommand? Loop through all products and check for duplicates. That way we can fail immediately if there are two products provided.

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 can handle most of the cases in the validator, and will do it. This line was going a bit ahead of what the typical use cases would be - this line e.g. prevents us from persisting a cycle count request for a product if e.g. two people were performing it at one time, but I guess it's barely to happen so we can live with just validator validation to group products and see if there are any duplicates in the list.

Copy link
Member

@ewaterman ewaterman Jan 2, 2025

Choose a reason for hiding this comment

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

it's an interesting point. We don't want two users creating CCRs for one product at the same time, causing Hibernate to give both the thumbs up (because nothing is in the db yet), and then we get stuck with both creating a CCR successfully for the same product, breaking the system because we have two active requests.

As you mentioned it's not a huge issue for us in our current state since parallel save operations are uncommon, but as we continue to scale up we'll likely see it more often. I think this speaks to a larger problem with how Hibernate propagates the database session across requests. That said, our system as a whole isn't really set up to handle operating at the scale at which it would start being a real problem, and so there are likely many other challenges we'll have to fix first (horizontal scaling with sticky sessions and a load balancer, database scaling and replication...) before this one even becomes a problem for us.

So if we do want to deal with the problem, it'll probably require a deeper investigation as to the best approach to take, and that probably isn't needed for a while (at least I hope). For now I think we can assume it'll be fine.

// FIXME: Most probably, this will have to be modified not to include completed cycle counts
CycleCountRequest cycleCountRequest = CycleCountRequest.findByProductAndFacility(product, obj.facility)
if (cycleCountRequest) {
productCode = product.productCode
Copy link
Member

@ewaterman ewaterman Jan 2, 2025

Choose a reason for hiding this comment

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

using productCode causes this to do a lookup on product for each product in the list, correct? I was hoping we could do this validation entirely on productId (which I was assuming would avoid the lookup since we already have the ids in the request body).

But actually this is making me realize I don't know how Grails is binding domains to request/command objects. If we use only productIds, is it smart enough to know not to fetch the products 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.

Product is bound at the binding step, so at this point nothing is fetched. It also doesn't cause any N+1, because productCode is a string field. Potential N+1 would be caused, if I was accessing a collection here.

Aside: I only store and assign the productCode here for a user-friendly validation message, so that the user knows which product it is about (id is not always user-friendly for products, because it's not always id == productCode)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

And also I didn't see a point to have the property String productId in the command instead of Product product, because anyway we'd have to fetch the product at some point, and here, Grails does it automatically for us.

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 also gives us much flexibility while validating in the constraints and in the validator, because we can already operate on the product and its properties, instead of validating the String property, where the only possibilities would be to validate if it's null/blank.

Copy link
Member

@ewaterman ewaterman Jan 2, 2025

Choose a reason for hiding this comment

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

I was assuming we could bypass fetching the Product at the binding step entirely and just use the id since Product isn't actually used anywhere (except your error case but I'm fine with fetching it there). All we do is map it to the request.

But thinking about it, we do need to verify that the given id is pointing to a real product, so we'll need the lookup regardless. So given we're doing a lookup already, there's no point in not using the Product.

Okay I'm convinced.

@kchelstowski
Copy link
Collaborator Author

@ewaterman I rebuilt the logic and implemented the approach suggested by you, so that the payload would look like:

{
 "requests": [
     {
         "product": "10006",
         "facility": "8a8a9e9665c4f59d0165c54ec6b10027",
         "blindCount": true
     },
      {
         "product": "10006",
         "facility": "8a8a9e9665c4f59d0165c54ec6b10027",
         "blindCount": true
     },
      {
         "product": "10007",
         "facility": "8a8a9e9665c4f59d0165c54ec6b10027",
         "blindCount": true
     },
      {
         "product": "10007",
         "facility": "8a8a9e9665c4f59d0165c54ec6b10027",
         "blindCount": true
     }
 ]
}

I also addressed the rest of suggested improvements/change requests.

@kchelstowski kchelstowski requested a review from ewaterman January 3, 2025 15:06
// This doesn't check though if any duplicate is already persisted, this is checked in the CCR command below (.each + .validate() clause)
Map<String, List<Product>> duplicates = requests.product
.groupBy { it.productCode }
.findAll { it.value.size() > 1 }
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 probably do a countBy productCode (and then filter to > 1) so that you only need to maintain a List (of duplicate productCodes) instead of a map but that's a tiny optimization so I'm not worried about it.

requests.each { CycleCountRequestCommand command -> command.validate() }
// If any of elements have validation errors, throw an exception
if (requests.any { it.hasErrors() }) {
return false
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 instead of returning false you return the actual requests.errors? Would that save you from needing to build the errors yourself in the controller?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

it's not how the validator works. It expects you either to return true/false (validation passed/didn't pass) or to return a list with a key of an error message and optionally arguments of the message.
Looking for example at a few lines above

 if (!duplicates.isEmpty()) {
  return ['duplicateExists', duplicates.keySet().toString()]
}

this would point to the message:

cycleCountRequestBatchCommand.requests.duplicateExists

and the duplicates.keySet().toString() would be accesible via {3}.
Given that you would have:

return ['duplicateExists', duplicates.keySet().toString(), 'something']

the something would be accesible under {4} etc.
{0}, {1}, {2} are reserved for:

Parameters 0 to 2 are automatically mapped to 0: property name, 1: class name, 2: property value. Additional parameters are mapped starting at parameter 3.

The return false only indicates that there is an error in requests field, so that when you further access the command.errors.requests, it would not be empty.
More info here: https://docs.grails.org/latest/ref/Constraints/validator.html

Copy link
Member

Choose a reason for hiding this comment

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

I see, so you can only return a single failure here but we need to return a list of them. That's annoying.

Okay yes then we'll need to parse the errors as you're doing. Looking through the code, it seems like that's what we've done historically as well (ProductController.batchSave or ProductSupplierAttributeApiController.updateAttributes for example)

(I found a blog post which describes a method of handling this at the validator level, but it feels hacky so I don't think we should do it.)

I've been thinking about how we can standardize our error messages across our system and so maybe there's a way to do this type of "compiling all errors together" work more generically, but for now I'm good with your solution.

Thanks for doing the command object refactoring by the way. I wasn't expecting it to make this validation so messy.


class CycleCountRequestCommand implements Validateable {

Location facility
Copy link
Member

Choose a reason for hiding this comment

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

The URI is /api/facilities/{facility_id}/cycle-counts/requests/batch so you could pull the facility value from there instead of requiring it in the request body. Thoughts?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Theoretically I could, but I would lose any validation for facility. Having it here, I don't have to check manually whether it's null or not, because it would be validated out of the box (remember that potentially users might use the api and paste into {facility_id} whatever they want).

Copy link
Member

Choose a reason for hiding this comment

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

that makes sense to me, but then we're completely ignoring the facility in the URI. We could add a check that makes sure the one in the URI matches the one in the request body, but that feels like we're doing redundant work. IMO it's best to have it in a single place to avoid confusion.

And since we want our GET APIs to be able to potentially fetch across facilities (I believe this was Justin's reasoning behind having the "facility" concept instead of using the logged-in location like we do elsewhere), then we'd need it in the URI itself. So because we need it there, I see it as redundant to include it elsewhere.

But I see your point. Having it in the request body gives us automatic validation. However I think if we're going to be including facility in ALL our APIs going forward (which I think is what Justin wants), then maybe we can be smarter about this somehow since all our APIs are going to share some validation on that field.

I'm realizing that we're missing a validation step here to verify that the user has permissions to operate on the requesting facility, so maybe we can have a "getAndValidate(facility, user)" service method on location that gets called early on. Or we could get real funky and create a custom @FacilityAPI (or whatever) annotation that binds the facility for us at the controller level. I'm not sure what's best here since I haven't fully thought it through.

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 added beforeValidate clause so that the facility is bound to the one that is provided in the URL.

CycleCountRequestStatus status

// To be included when cycle count table is created
// CycleCount cycleCount
Copy link
Collaborator

Choose a reason for hiding this comment

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

Was it agreed that it can be skipped now? To add it we will have to write a second migration. Would not it be better if we had the core cycle count domain here as well?


static constraints = {
id generator: "uuid"
product(nullable: true, unique: "facility")
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is the cycle count request meant to be deleted at the end once it is completed? Right now if now if it won't be deleted, then it will prevent to requesting it again in the future due to this constraint. Was there a case when the product is null on the CCR level?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good question. I would say we would want to keep the history (?), so that we don't delete a request?
As for product nullable, I asked @ewaterman about that, because for me it didn't make sense, and he said we would want to support some other functionalities in the future that you would be able to create a CCR without a product, but probably for a binLocation (?).

Copy link
Member

Choose a reason for hiding this comment

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

yes we never delete a request so this shouldn't be unique. Good catch.

and yes the only reason it's nullable is for future requirements where we can request a count on a location instead of on a product.

errors.addError(error)
}
}
throw new ValidationException("Invalid cycle count", errors)
Copy link
Collaborator

Choose a reason for hiding this comment

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

"Invalid cycle count request"?

static constraints = {
product(nullable: true, validator: { Product product, CycleCountRequestCommand obj ->
// TODO: Most probably, this will have to be modified not to include completed cycle counts
CycleCountRequest cycleCountRequest = CycleCountRequest.findByProductAndFacility(product, obj.facility)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Again the same question, did we want to delete the requests after completing, or should we look here for only PENDING Requests to find duplicates NVM, I just read the TODO, but the other constraint in the CCR domain itself need either improvement or TODO as well.

Copy link
Collaborator

Choose a reason for hiding this comment

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

But I think you could already use the findByProductAndFacilityAndStatusNotInList(product, obj.facility, [COMPLETED, CANCELED])

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, given that you confirmed that, I will add it. I intentionally skipped the statuses part until it is confirmed, but I guess those two statuses are safe bet that they will be here.

@awalkowiak awalkowiak merged commit 6fa9a07 into develop Jan 9, 2025
8 checks passed
@awalkowiak awalkowiak deleted the OBPIH-6903 branch January 9, 2025 14:58
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