feat(medusa): complete cart create reservation (#7250)

This commit is contained in:
Carlos R. L. Rodrigues
2024-05-06 14:36:55 -03:00
committed by GitHub
parent a736e728b8
commit 5228b14ca9
18 changed files with 195 additions and 77 deletions

View File

@@ -0,0 +1,7 @@
---
"@medusajs/core-flows": patch
"@medusajs/types": patch
"@medusajs/utils": patch
---
Create stock reservation on complete cart flow

View File

@@ -709,10 +709,9 @@ medusaIntegrationTestRunner({
expect(errors).toEqual([
{
action: "confirm-item-inventory-as-step",
action: "validate-variant-prices",
handlerType: "invoke",
error: expect.objectContaining({
// TODO: FIX runAsStep nested errors
message: expect.stringContaining(
`Variants with IDs ${product.variants[0].id} do not have a price`
),

View File

@@ -1480,6 +1480,8 @@ medusaIntegrationTestRunner({
let shippingProfile
let fulfillmentSet
let shippingOption
let stockLocation
let inventoryItem
beforeEach(async () => {
await setupTaxStructure(taxModule)
@@ -1510,7 +1512,6 @@ medusaIntegrationTestRunner({
variants: [
{
title: "Test variant",
inventory_quantity: 10,
prices: [
{
currency_code: "usd",
@@ -1528,7 +1529,7 @@ medusaIntegrationTestRunner({
)
).data.product
const stockLocation = (
stockLocation = (
await api.post(
`/admin/stock-locations`,
{ name: "test location" },
@@ -1536,6 +1537,25 @@ medusaIntegrationTestRunner({
)
).data.stock_location
inventoryItem = (
await api.post(
`/admin/inventory-items`,
{
sku: "12345",
},
adminHeaders
)
).data.inventory_item
await api.post(
`/admin/inventory-items/${inventoryItem.id}/location-levels`,
{
location_id: stockLocation.id,
stocked_quantity: 10,
},
adminHeaders
)
await api.post(
`/admin/stock-locations/${stockLocation.id}/sales-channels`,
{ add: [salesChannel.id] },
@@ -1563,6 +1583,14 @@ medusaIntegrationTestRunner({
[Modules.STOCK_LOCATION]: { stock_location_id: stockLocation.id },
[Modules.FULFILLMENT]: { fulfillment_set_id: fulfillmentSet.id },
},
{
[Modules.PRODUCT]: {
variant_id: product.variants[0].id,
},
[Modules.INVENTORY]: {
inventory_item_id: inventoryItem.id,
},
},
])
shippingOption = (
@@ -1590,7 +1618,7 @@ medusaIntegrationTestRunner({
).data.shipping_option
})
it("should create an order", async () => {
it("should create an order and create item reservations", async () => {
const cartResponse = await api.post(`/store/carts`, {
currency_code: "usd",
email: "tony@stark-industries.com",
@@ -1602,8 +1630,7 @@ medusaIntegrationTestRunner({
province: "ny",
postal_code: "94016",
},
// TODO: inventory isn't being managed on a product level
// sales_channel_id: salesChannel.id,
sales_channel_id: salesChannel.id,
items: [{ quantity: 1, variant_id: product.variants[0].id }],
})
@@ -1661,6 +1688,19 @@ medusaIntegrationTestRunner({
}),
})
)
const reservation = await api.get(`/admin/reservations`, adminHeaders)
const reservationItem = reservation.data.reservations[0]
expect(reservationItem).toEqual(
expect.objectContaining({
id: expect.stringContaining("resitem_"),
location_id: stockLocation.id,
inventory_item_id: inventoryItem.id,
quantity: cartResponse.data.cart.items[0].quantity,
line_item_id: cartResponse.data.cart.items[0].id,
})
)
})
})
})

View File

@@ -1,5 +1,5 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { IInventoryService } from "@medusajs/types"
import { IInventoryServiceNext } from "@medusajs/types"
import { promiseAll } from "@medusajs/utils"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
import { MedusaError } from "medusa-core-utils"
@@ -18,7 +18,7 @@ export const confirmInventoryStepId = "confirm-inventory-step"
export const confirmInventoryStep = createStep(
confirmInventoryStepId,
async (data: StepInput, { container }) => {
const inventoryService = container.resolve<IInventoryService>(
const inventoryService = container.resolve<IInventoryServiceNext>(
ModuleRegistrationName.INVENTORY
)

View File

@@ -22,4 +22,4 @@ export * from "./set-tax-lines-for-items"
export * from "./update-cart-promotions"
export * from "./update-carts"
export * from "./validate-cart-shipping-options"
export * from "./validate-variants-existence"
export * from "./validate-variant-prices"

View File

@@ -0,0 +1,51 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { IInventoryServiceNext } from "@medusajs/types"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
interface StepInput {
items: {
id?: string
inventory_item_id: string
required_quantity: number
allow_backorder: boolean
quantity: number
location_ids: string[]
}[]
}
export const reserveInventoryStepId = "reserve-inventory-step"
export const reserveInventoryStep = createStep(
reserveInventoryStepId,
async (data: StepInput, { container }) => {
const inventoryService = container.resolve<IInventoryServiceNext>(
ModuleRegistrationName.INVENTORY
)
const items = data.items.map((item) => ({
line_item_id: item.id,
inventory_item_id: item.inventory_item_id,
quantity: item.required_quantity * item.quantity,
allow_backorder: item.allow_backorder,
location_id: item.location_ids[0],
}))
const reservations = await inventoryService.createReservationItems(items)
return new StepResponse(void 0, {
reservations: reservations.map((r) => r.id),
})
},
async (data, { container }) => {
if (!data) {
return
}
const inventoryService = container.resolve<IInventoryServiceNext>(
ModuleRegistrationName.INVENTORY
)
await inventoryService.deleteReservationItems(data.reservations)
return new StepResponse()
}
)

View File

@@ -0,0 +1,31 @@
import { BigNumberInput } from "@medusajs/types"
import { MedusaError, isDefined } from "@medusajs/utils"
import { createStep } from "@medusajs/workflows-sdk"
interface StepInput {
variants: {
id: string
calculated_price?: BigNumberInput
}[]
}
export const validateVariantPricesStepId = "validate-variant-prices"
export const validateVariantPricesStep = createStep(
validateVariantPricesStepId,
async (data: StepInput, { container }) => {
const priceNotFound: string[] = []
for (const variant of data.variants) {
if (!isDefined(variant.calculated_price)) {
priceNotFound.push(variant.id)
}
}
if (priceNotFound.length > 0) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Variants with IDs ${priceNotFound.join(", ")} do not have a price`
)
}
}
)

View File

@@ -1,38 +0,0 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { IProductModuleService } from "@medusajs/types"
import { MedusaError } from "@medusajs/utils"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
interface StepInput {
variantIds: string[]
}
export const validateVariantsExistStepId = "validate-variants-exist"
export const validateVariantsExistStep = createStep(
validateVariantsExistStepId,
async (data: StepInput, { container }) => {
const productModuleService = container.resolve<IProductModuleService>(
ModuleRegistrationName.PRODUCT
)
const variants = await productModuleService.listVariants(
{ id: data.variantIds },
{ select: ["id"] }
)
const variantIdToData = new Set(variants.map((v) => v.id))
const notFoundVariants = new Set(
[...data.variantIds].filter((x) => !variantIdToData.has(x))
)
if (notFoundVariants.size) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Variants with IDs ${[...notFoundVariants].join(", ")} do not exist`
)
}
return new StepResponse(Array.from(variants.map((v) => v.id)))
}
)

View File

@@ -8,6 +8,7 @@ interface ConfirmInventoryPreparationInput {
required_quantity: number
}[]
items: {
id?: string
variant_id?: string
quantity: BigNumberInput
}[]
@@ -20,6 +21,7 @@ interface ConfirmInventoryPreparationInput {
}
interface ConfirmInventoryItem {
id?: string
inventory_item_id: string
required_quantity: number
allow_backorder: boolean
@@ -63,6 +65,7 @@ export const prepareConfirmInventoryInput = ({
}
itemsToConfirm.push({
id: item.id,
inventory_item_id: variantInventoryItem.inventory_item_id,
required_quantity: variantInventoryItem.required_quantity,
allow_backorder: !!variant.allow_backorder,

View File

@@ -11,6 +11,7 @@ import { useRemoteQueryStep } from "../../../common/steps/use-remote-query"
import { addToCartStep, refreshCartShippingMethodsStep } from "../steps"
import { refreshCartPromotionsStep } from "../steps/refresh-cart-promotions"
import { updateTaxLinesStep } from "../steps/update-tax-lines"
import { validateVariantPricesStep } from "../steps/validate-variant-prices"
import {
cartFieldsForRefreshSteps,
productVariantsFields,
@@ -51,6 +52,8 @@ export const addToCartWorkflow = createWorkflow(
throw_if_key_not_found: true,
})
validateVariantPricesStep({ variants })
confirmVariantInventoryWorkflow.runAsStep({
input: {
sales_channel_id: input.cart.sales_channel_id as string,

View File

@@ -6,6 +6,7 @@ import {
} from "@medusajs/workflows-sdk"
import { useRemoteQueryStep } from "../../../common"
import { createOrderFromCartStep } from "../steps"
import { reserveInventoryStep } from "../steps/reserve-inventory"
import { updateTaxLinesStep } from "../steps/update-tax-lines"
import { completeCartFields } from "../utils/fields"
import { confirmVariantInventoryWorkflow } from "./confirm-variant-inventory"
@@ -40,6 +41,7 @@ export const completeCartWorkflow = createWorkflow(
data.cart.items.forEach((item) => {
allItems.push({
id: item.id,
variant_id: item.variant_id,
quantity: item.quantity,
})
@@ -54,9 +56,8 @@ export const completeCartWorkflow = createWorkflow(
}
)
confirmVariantInventoryWorkflow.runAsStep({
const formatedInventoryItems = confirmVariantInventoryWorkflow.runAsStep({
input: {
ignore_price_check: true,
sales_channel_id,
variants,
items,
@@ -65,6 +66,8 @@ export const completeCartWorkflow = createWorkflow(
updateTaxLinesStep({ cart_or_cart_id: cart, force_tax_calculation: true })
reserveInventoryStep(formatedInventoryItems)
const finalCart = useRemoteQueryStep({
entry_point: "cart",
fields: completeCartFields,

View File

@@ -1,5 +1,5 @@
import { ConfirmVariantInventoryWorkflowInputDTO } from "@medusajs/types"
import { MedusaError, deepFlatMap, isDefined } from "@medusajs/utils"
import { MedusaError, deepFlatMap } from "@medusajs/utils"
import {
WorkflowData,
createWorkflow,
@@ -8,13 +8,25 @@ import {
import { confirmInventoryStep } from "../steps"
import { prepareConfirmInventoryInput } from "../utils/prepare-confirm-inventory-input"
interface Output {
items: {
id?: string
inventory_item_id: string
required_quantity: number
allow_backorder: boolean
quantity: number
location_ids: string[]
}[]
}
export const confirmVariantInventoryWorkflowId = "confirm-item-inventory"
export const confirmVariantInventoryWorkflow = createWorkflow(
confirmVariantInventoryWorkflowId,
(input: WorkflowData<ConfirmVariantInventoryWorkflowInputDTO>) => {
(
input: WorkflowData<ConfirmVariantInventoryWorkflowInputDTO>
): WorkflowData<Output> => {
const confirmInventoryInput = transform({ input }, (data) => {
const productVariantInventoryItems = new Map<string, any>()
const priceNotFound: string[] = []
const stockLocationIds = new Set<string>()
const allVariants = new Map<string, any>()
let hasSalesChannelStockLocation = false
@@ -26,6 +38,10 @@ export const confirmVariantInventoryWorkflow = createWorkflow(
data.input,
"variants.inventory_items.inventory.location_levels.stock_locations.sales_channels",
({ variants, inventory_items, stock_locations, sales_channels }) => {
if (!variants) {
return
}
if (
!hasSalesChannelStockLocation &&
sales_channels?.id === salesChannelId
@@ -33,19 +49,19 @@ export const confirmVariantInventoryWorkflow = createWorkflow(
hasSalesChannelStockLocation = true
}
if (!isDefined(variants.calculated_price)) {
priceNotFound.push(variants.id)
if (stock_locations) {
stockLocationIds.add(stock_locations.id)
}
stockLocationIds.add(stock_locations.id)
const inventoryItemId = inventory_items.inventory_item_id
if (!productVariantInventoryItems.has(inventoryItemId)) {
productVariantInventoryItems.set(inventoryItemId, {
variant_id: inventory_items.variant_id,
inventory_item_id: inventoryItemId,
required_quantity: inventory_items.required_quantity,
})
if (inventory_items) {
const inventoryItemId = inventory_items.inventory_item_id
if (!productVariantInventoryItems.has(inventoryItemId)) {
productVariantInventoryItems.set(inventoryItemId, {
variant_id: inventory_items.variant_id,
inventory_item_id: inventoryItemId,
required_quantity: inventory_items.required_quantity,
})
}
}
if (!allVariants.has(variants.id)) {
@@ -72,13 +88,6 @@ export const confirmVariantInventoryWorkflow = createWorkflow(
)
}
if (priceNotFound.length && !data.input.ignore_price_check) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Variants with IDs ${priceNotFound.join(", ")} do not have a price`
)
}
const items = prepareConfirmInventoryInput({
product_variant_inventory_items: Array.from(
productVariantInventoryItems.values()
@@ -92,5 +101,7 @@ export const confirmVariantInventoryWorkflow = createWorkflow(
})
confirmInventoryStep(confirmInventoryInput)
return confirmInventoryInput
}
)

View File

@@ -15,6 +15,7 @@ import {
} from "../steps"
import { refreshCartPromotionsStep } from "../steps/refresh-cart-promotions"
import { updateTaxLinesStep } from "../steps/update-tax-lines"
import { validateVariantPricesStep } from "../steps/validate-variant-prices"
import { productVariantsFields } from "../utils/fields"
import { prepareLineItemData } from "../utils/prepare-line-item-data"
import { confirmVariantInventoryWorkflow } from "./confirm-variant-inventory"
@@ -68,6 +69,8 @@ export const createCartWorkflow = createWorkflow(
throw_if_key_not_found: true,
})
validateVariantPricesStep({ variants })
confirmVariantInventoryWorkflow.runAsStep({
input: {
sales_channel_id: salesChannel.id,

View File

@@ -78,7 +78,7 @@ export const listShippingOptionsForCartWorkflow = createWorkflow(
({ shipping_options }) => {
const { calculated_price, ...options } = shipping_options ?? {}
if (!calculated_price) {
if (options?.id && !calculated_price) {
optionsMissingPrices.push(options.id)
}

View File

@@ -8,6 +8,7 @@ import { useRemoteQueryStep } from "../../../common/steps/use-remote-query"
import { updateLineItemsStep } from "../../line-item/steps"
import { refreshCartShippingMethodsStep } from "../steps"
import { refreshCartPromotionsStep } from "../steps/refresh-cart-promotions"
import { validateVariantPricesStep } from "../steps/validate-variant-prices"
import {
cartFieldsForRefreshSteps,
productVariantsFields,
@@ -47,6 +48,8 @@ export const updateLineItemInCartWorkflow = createWorkflow(
throw_if_key_not_found: true,
})
validateVariantPricesStep({ variants })
const items = transform({ input }, (data) => {
return [data.input.item]
})

View File

@@ -13,7 +13,6 @@ import { findOrCreateCustomerStep } from "../../definition/cart/steps/find-or-cr
import { findSalesChannelStep } from "../../definition/cart/steps/find-sales-channel"
import { getVariantPriceSetsStep } from "../../definition/cart/steps/get-variant-price-sets"
import { getVariantsStep } from "../../definition/cart/steps/get-variants"
import { validateVariantsExistStep } from "../../definition/cart/steps/validate-variants-existence"
import { prepareConfirmInventoryInput } from "../../definition/cart/utils/prepare-confirm-inventory-input"
import { prepareLineItemData } from "../../definition/cart/utils/prepare-line-item-data"
import { createOrdersStep, updateOrderTaxLinesStep } from "../steps"
@@ -39,8 +38,8 @@ export const createOrdersWorkflow = createWorkflow(
findOrCreateCustomerStep({
customerId: input.customer_id,
email: input.email,
}),
validateVariantsExistStep({ variantIds })
})
// validateVariantsExistStep({ variantIds })
)
const variants = getVariantsStep({

View File

@@ -124,8 +124,6 @@ export interface CompleteCartWorkflowInputDTO {
}
export interface ConfirmVariantInventoryWorkflowInputDTO {
ignore_price_check?: boolean
sales_channel_id: string
variants: {
id: string

View File

@@ -83,6 +83,11 @@ export function deepFlatMap(
}
} else {
if (Array.isArray(element[currentKey])) {
if (element[currentKey].length === 0) {
callback({ ...context })
continue
}
element[currentKey].forEach((item) => {
stack.push({
element: item,