fix(medusa): update create fulfillment flow (#3172)

* update create fulfillment flow

* move transaction service creation close to where it's used

* integration tests

* fix feedback

* use transformBody

* add changeset
This commit is contained in:
Philip Korsholm authored and GitHub committed 2023-02-28 16:28:11 +01:00
1 parent 7738525401
commit 5eb61fa0ef
6 files changed
+367 -46

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"@medusajs/medusa": minor
---
Add inventory management to create-fulfillment flow
@@ -0,0 +1,266 @@
const path = require("path")
const { bootstrapApp } = require("../../../../helpers/bootstrap-app")
const { initDb, useDb } = require("../../../../helpers/use-db")
const { setPort, useApi } = require("../../../../helpers/use-api")
const adminSeeder = require("../../../helpers/admin-seeder")
const cartSeeder = require("../../../helpers/cart-seeder")
const { simpleProductFactory } = require("../../../../api/factories")
const { simpleSalesChannelFactory } = require("../../../../api/factories")
const {
simpleOrderFactory,
simpleRegionFactory,
} = require("../../../factories")
jest.setTimeout(30000)
const adminHeaders = { headers: { Authorization: "Bearer test_token" } }
describe("/store/carts", () => {
let express
let appContainer
let dbConnection
const doAfterEach = async () => {
const db = useDb()
return await db.teardown()
}
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
dbConnection = await initDb({ cwd })
const { container, app, port } = await bootstrapApp({ cwd })
appContainer = container
setPort(port)
express = app.listen(port, (err) => {
process.send(port)
})
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
express.close()
})
afterEach(async () => {
jest.clearAllMocks()
const db = useDb()
return await db.teardown()
})
describe("POST /store/carts/:id", () => {
let order
let locationId
let invItemId
let variantId
let prodVarInventoryService
beforeEach(async () => {
const api = useApi()
prodVarInventoryService = appContainer.resolve(
"productVariantInventoryService"
)
const inventoryService = appContainer.resolve("inventoryService")
const stockLocationService = appContainer.resolve("stockLocationService")
const salesChannelLocationService = appContainer.resolve(
"salesChannelLocationService"
)
const r = await simpleRegionFactory(dbConnection, {})
await simpleSalesChannelFactory(dbConnection, {
id: "test-channel",
is_default: true,
})
await adminSeeder(dbConnection)
const product = await simpleProductFactory(dbConnection, {
id: "product1",
sales_channels: [{ id: "test-channel" }],
})
variantId = product.variants[0].id
const sl = await stockLocationService.create({ name: "test-location" })
locationId = sl.id
await salesChannelLocationService.associateLocation(
"test-channel",
locationId
)
const invItem = await inventoryService.createInventoryItem({
sku: "test-sku",
})
invItemId = invItem.id
await prodVarInventoryService.attachInventoryItem(variantId, invItem.id)
await inventoryService.createInventoryLevel({
inventory_item_id: invItem.id,
location_id: locationId,
stocked_quantity: 1,
})
const { id: orderId } = await simpleOrderFactory(dbConnection, {
sales_channel: "test-channel",
line_items: [
{
variant_id: variantId,
quantity: 2,
id: "line-item-id",
},
],
shipping_methods: [
{
shipping_option: {
region_id: r.id,
},
},
],
})
const orderRes = await api.get(`/admin/orders/${orderId}`, adminHeaders)
order = orderRes.data.order
const inventoryItem = await api.get(
`/admin/inventory-items/${invItem.id}`,
adminHeaders
)
expect(inventoryItem.data.inventory_item.location_levels[0]).toEqual(
expect.objectContaining({
stocked_quantity: 1,
reserved_quantity: 0,
available_quantity: 1,
})
)
})
describe("Fulfillments", () => {
const lineItemId = "line-item-id"
it("Adjusts reservations on successful fulfillment with reservation", async () => {
const api = useApi()
await prodVarInventoryService.reserveQuantity(variantId, 1, {
locationId: locationId,
lineItemId: order.items[0].id,
})
let inventoryItem = await api.get(
`/admin/inventory-items/${invItemId}`,
adminHeaders
)
expect(inventoryItem.data.inventory_item.location_levels[0]).toEqual(
expect.objectContaining({
stocked_quantity: 1,
reserved_quantity: 1,
available_quantity: 0,
})
)
const fulfillmentRes = await api.post(
`/admin/orders/${order.id}/fulfillment`,
{
items: [{ item_id: lineItemId, quantity: 1 }],
location_id: locationId,
},
adminHeaders
)
expect(fulfillmentRes.status).toBe(200)
expect(fulfillmentRes.data.order.fulfillment_status).toBe(
"partially_fulfilled"
)
inventoryItem = await api.get(
`/admin/inventory-items/${invItemId}`,
adminHeaders
)
const reservations = await api.get(
`/admin/reservations?inventory_item_id[]=${invItemId}`,
adminHeaders
)
expect(reservations.data.reservations.length).toBe(0)
expect(inventoryItem.data.inventory_item.location_levels[0]).toEqual(
expect.objectContaining({
stocked_quantity: 0,
reserved_quantity: 0,
available_quantity: 0,
})
)
})
it("adjusts inventory levels on successful fulfillment without reservation", async () => {
const api = useApi()
const fulfillmentRes = await api.post(
`/admin/orders/${order.id}/fulfillment`,
{
items: [{ item_id: lineItemId, quantity: 1 }],
location_id: locationId,
},
adminHeaders
)
expect(fulfillmentRes.status).toBe(200)
expect(fulfillmentRes.data.order.fulfillment_status).toBe(
"partially_fulfilled"
)
const inventoryItem = await api.get(
`/admin/inventory-items/${invItemId}`,
adminHeaders
)
expect(inventoryItem.data.inventory_item.location_levels[0]).toEqual(
expect.objectContaining({
stocked_quantity: 0,
reserved_quantity: 0,
available_quantity: 0,
})
)
})
it("Fails to create fulfillment if there is not enough inventory at the fulfillment location", async () => {
const api = useApi()
const err = await api
.post(
`/admin/orders/${order.id}/fulfillment`,
{
items: [{ item_id: lineItemId, quantity: 2 }],
location_id: locationId,
},
adminHeaders
)
.catch((e) => e)
expect(err.response.status).toBe(400)
expect(err.response.data).toEqual({
type: "not_allowed",
message: `Insufficient stock for item: ${order.items[0].title}`,
})
const inventoryItem = await api.get(
`/admin/inventory-items/${invItemId}`,
adminHeaders
)
expect(inventoryItem.data.inventory_item.location_levels[0]).toEqual(
expect.objectContaining({
stocked_quantity: 1,
reserved_quantity: 0,
available_quantity: 1,
})
)
})
})
})
})
@@ -5,6 +5,9 @@ import {
Order, Order,
PaymentStatus, PaymentStatus,
FulfillmentStatus, FulfillmentStatus,
SalesChannel,
Discount,
isString,
} from "@medusajs/medusa" } from "@medusajs/medusa"
import { import {
@@ -24,6 +27,11 @@ import {
ShippingMethodFactoryData, ShippingMethodFactoryData,
simpleShippingMethodFactory, simpleShippingMethodFactory,
} from "./simple-shipping-method-factory" } from "./simple-shipping-method-factory"
import {
SalesChannelFactoryData,
simpleSalesChannelFactory,
} from "../../api/factories"
import { isDefined } from "medusa-core-utils"
export type OrderFactoryData = { export type OrderFactoryData = {
id?: string id?: string
@@ -33,6 +41,7 @@ export type OrderFactoryData = {
email?: string | null email?: string | null
currency_code?: string currency_code?: string
tax_rate?: number | null tax_rate?: number | null
sales_channel?: string | SalesChannelFactoryData
line_items?: LineItemFactoryData[] line_items?: LineItemFactoryData[]
discounts?: DiscountFactoryData[] discounts?: DiscountFactoryData[]
shipping_address?: AddressFactoryData shipping_address?: AddressFactoryData
@@ -72,15 +81,14 @@ export const simpleOrderFactory = async (
}) })
const customer = await manager.save(customerToSave) const customer = await manager.save(customerToSave)
let discounts = [] let discounts: Discount[] = []
if (typeof data.discounts !== "undefined") { if (typeof data.discounts !== "undefined") {
discounts = await Promise.all( discounts = await Promise.all(
data.discounts.map((d) => simpleDiscountFactory(connection, d, seed)) data.discounts.map((d) => simpleDiscountFactory(connection, d, seed))
) )
} }
const id = data.id || `simple-order-${Math.random() * 1000}` const id = data.id || `simple-order-${Math.random() * 1000}`
const toSave = manager.create(Order, { const toCreate: Partial<Order> = {
id, id,
discounts, discounts,
payment_status: data.payment_status ?? PaymentStatus.AWAITING, payment_status: data.payment_status ?? PaymentStatus.AWAITING,
@@ -92,16 +100,44 @@ export const simpleOrderFactory = async (
currency_code: currencyCode, currency_code: currencyCode,
tax_rate: taxRate, tax_rate: taxRate,
shipping_address_id: address.id, shipping_address_id: address.id,
}) }
const order = await manager.save(toSave) let sc_id
if (isDefined(data.sales_channel)) {
let sc
if (isString(data.sales_channel)) {
sc = await manager.findOne(SalesChannel, {
where: { id: data.sales_channel },
})
}
if (!sc) {
sc = await simpleSalesChannelFactory(
connection,
isString(data.sales_channel)
? { id: data.sales_channel }
: data.sales_channel
)
}
sc_id = sc.id
}
if (sc_id) {
toCreate.sales_channel_id = sc_id
}
const toSave = manager.create(Order, toCreate)
const order = await manager.save(Order, toSave)
const shippingMethods = data.shipping_methods || [] const shippingMethods = data.shipping_methods || []
for (const sm of shippingMethods) { for (const sm of shippingMethods) {
await simpleShippingMethodFactory(connection, { ...sm, order_id: order.id }) await simpleShippingMethodFactory(connection, { ...sm, order_id: order.id })
} }
const items = data.line_items const items = data.line_items || []
for (const item of items) { for (const item of items) {
await simpleLineItemFactory(connection, { ...item, order_id: id }) await simpleLineItemFactory(connection, { ...item, order_id: id })
} }
@@ -27,6 +27,7 @@ import regionRoutes from "./regions"
import reservationRoutes from "./reservations" import reservationRoutes from "./reservations"
import returnReasonRoutes from "./return-reasons" import returnReasonRoutes from "./return-reasons"
import returnRoutes from "./returns" import returnRoutes from "./returns"
import reservationRoutes from "./reservations"
import salesChannelRoutes from "./sales-channels" import salesChannelRoutes from "./sales-channels"
import shippingOptionRoutes from "./shipping-options" import shippingOptionRoutes from "./shipping-options"
import shippingProfileRoutes from "./shipping-profiles" import shippingProfileRoutes from "./shipping-profiles"
@@ -101,6 +102,7 @@ export default (app, container, config) => {
reservationRoutes(route) reservationRoutes(route)
returnReasonRoutes(route) returnReasonRoutes(route)
returnRoutes(route) returnRoutes(route)
reservationRoutes(route)
salesChannelRoutes(route) salesChannelRoutes(route)
shippingOptionRoutes(route, featureFlagRouter) shippingOptionRoutes(route, featureFlagRouter)
shippingProfileRoutes(route) shippingProfileRoutes(route)
@@ -97,39 +97,49 @@ import { FindParams } from "../../../../types/common"
export default async (req, res) => { export default async (req, res) => {
const { id } = req.params const { id } = req.params
const validated = req.validatedBody const { validatedBody } = req as {
validatedBody: AdminPostOrdersOrderFulfillmentsReq
}
const orderService: OrderService = req.scope.resolve("orderService") const orderService: OrderService = req.scope.resolve("orderService")
const pvInventoryService: ProductVariantInventoryService = req.scope.resolve( const pvInventoryService: ProductVariantInventoryService = req.scope.resolve(
"productVariantInventoryService" "productVariantInventoryService"
) )
const manager: EntityManager = req.scope.resolve("manager") const manager: EntityManager = req.scope.resolve("manager")
await manager.transaction(async (transactionManager) => { await manager.transaction(async (transactionManager) => {
const { fulfillments: existingFulfillments } = await orderService const orderServiceTx = orderService.withTransaction(transactionManager)
.withTransaction(transactionManager)
.retrieve(id, { const { fulfillments: existingFulfillments } =
await orderServiceTx.retrieve(id, {
relations: ["fulfillments"], relations: ["fulfillments"],
}) })
const existingFulfillmentMap = new Map( const existingFulfillmentMap = new Map(
existingFulfillments.map((fulfillment) => [fulfillment.id, fulfillment]) existingFulfillments.map((fulfillment) => [fulfillment.id, fulfillment])
) )
const { fulfillments } = await orderService await orderServiceTx.createFulfillment(id, validatedBody.items, {
.withTransaction(transactionManager) metadata: validatedBody.metadata,
.createFulfillment(id, validated.items, { no_notification: validatedBody.no_notification,
metadata: validated.metadata, })
no_notification: validated.no_notification,
if (validatedBody.location_id) {
const { fulfillments } = await orderServiceTx.retrieve(id, {
relations: [
"fulfillments",
"fulfillments.items",
"fulfillments.items.item",
],
}) })
const pvInventoryServiceTx = const pvInventoryServiceTx =
pvInventoryService.withTransaction(transactionManager) pvInventoryService.withTransaction(transactionManager)
if (validated.location_id) {
await updateInventoryAndReservations( await updateInventoryAndReservations(
fulfillments.filter((f) => !existingFulfillmentMap[f.id]), fulfillments.filter((f) => !existingFulfillmentMap[f.id]),
{ {
inventoryService: pvInventoryServiceTx, inventoryService: pvInventoryServiceTx,
locationId: validated.location_id, locationId: validatedBody.location_id,
} }
) )
} }
@@ -151,33 +161,35 @@ const updateInventoryAndReservations = async (
) => { ) => {
const { inventoryService, locationId } = context const { inventoryService, locationId } = context
fulfillments.map(async ({ items }) => { await Promise.all(
await inventoryService.validateInventoryAtLocation( fulfillments.map(async ({ items }) => {
items.map(({ item, quantity }) => ({ ...item, quantity } as LineItem)), await inventoryService.validateInventoryAtLocation(
locationId items.map(({ item, quantity }) => ({ ...item, quantity } as LineItem)),
) locationId
)
await Promise.all( await Promise.all(
items.map(async ({ item, quantity }) => { items.map(async ({ item, quantity }) => {
if (!item.variant_id) { if (!item.variant_id) {
return return
} }
await inventoryService.adjustReservationsQuantityByLineItem( await inventoryService.adjustReservationsQuantityByLineItem(
item.id, item.id,
item.variant_id, item.variant_id,
locationId, locationId,
-quantity -quantity
) )
await inventoryService.adjustInventory( await inventoryService.adjustInventory(
item.variant_id, item.variant_id,
locationId, locationId,
-quantity -quantity
) )
}) })
) )
}) })
)
} }
/** /**
@@ -447,7 +447,7 @@ class ProductVariantInventoryService extends TransactionBaseService {
) )
const reservationQtyUpdate = const reservationQtyUpdate =
reservation.quantity - reservation.quantity +
quantity * productVariantInventory.required_quantity quantity * productVariantInventory.required_quantity
if (reservationQtyUpdate === 0) { if (reservationQtyUpdate === 0) {
@@ -494,11 +494,11 @@ class ProductVariantInventoryService extends TransactionBaseService {
) )
for (const inventoryLevel of inventoryLevels) { for (const inventoryLevel of inventoryLevels) {
const pvInventoryItem = pviMap[inventoryLevel.inventory_item_id] const pvInventoryItem = pviMap.get(inventoryLevel.inventory_item_id)
if ( if (
!pvInventoryItem || !pvInventoryItem ||
pvInventoryItem.quantity * item.quantity > pvInventoryItem.required_quantity * item.quantity >
inventoryLevel.stocked_quantity inventoryLevel.stocked_quantity
) { ) {
throw new MedusaError( throw new MedusaError(