-
-
Notifications
You must be signed in to change notification settings - Fork 2.5k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix(medusa): update create fulfillment flow #3172
Changes from 14 commits
f13fd13
924f553
079e6be
79e5881
d2c4dff
43f770c
b310c55
f3d93a0
260aa26
5141f8f
520b300
6911c42
bf64954
23aed0e
a8ebac2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"@medusajs/medusa": minor | ||
--- | ||
|
||
Add inventory management to create-fulfillment flow |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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, | ||
}) | ||
) | ||
}) | ||
}) | ||
}) | ||
}) |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,6 +5,9 @@ import { | |
Order, | ||
PaymentStatus, | ||
FulfillmentStatus, | ||
SalesChannel, | ||
Discount, | ||
isString, | ||
} from "@medusajs/medusa" | ||
|
||
import { | ||
|
@@ -24,6 +27,11 @@ import { | |
ShippingMethodFactoryData, | ||
simpleShippingMethodFactory, | ||
} from "./simple-shipping-method-factory" | ||
import { | ||
SalesChannelFactoryData, | ||
simpleSalesChannelFactory, | ||
} from "../../api/factories" | ||
import { isDefined } from "medusa-core-utils" | ||
|
||
export type OrderFactoryData = { | ||
id?: string | ||
|
@@ -33,6 +41,7 @@ export type OrderFactoryData = { | |
email?: string | null | ||
currency_code?: string | ||
tax_rate?: number | null | ||
sales_channel?: string | SalesChannelFactoryData | ||
line_items?: LineItemFactoryData[] | ||
discounts?: DiscountFactoryData[] | ||
shipping_address?: AddressFactoryData | ||
|
@@ -72,15 +81,14 @@ export const simpleOrderFactory = async ( | |
}) | ||
const customer = await manager.save(customerToSave) | ||
|
||
let discounts = [] | ||
let discounts: Discount[] = [] | ||
if (typeof data.discounts !== "undefined") { | ||
discounts = await Promise.all( | ||
data.discounts.map((d) => simpleDiscountFactory(connection, d, seed)) | ||
) | ||
} | ||
|
||
const id = data.id || `simple-order-${Math.random() * 1000}` | ||
const toSave = manager.create(Order, { | ||
const toCreate: Partial<Order> = { | ||
id, | ||
discounts, | ||
payment_status: data.payment_status ?? PaymentStatus.AWAITING, | ||
|
@@ -92,16 +100,44 @@ export const simpleOrderFactory = async ( | |
currency_code: currencyCode, | ||
tax_rate: taxRate, | ||
shipping_address_id: address.id, | ||
}) | ||
} | ||
|
||
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 | ||
} | ||
Comment on lines
+106
to
+129
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. todo: if There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. if There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. LOL, I did not see the not in the if constraint. I need to clean my glasses |
||
|
||
const toSave = manager.create(Order, toCreate) | ||
|
||
const order = await manager.save(toSave) | ||
const order = await manager.save(Order, toSave) | ||
|
||
const shippingMethods = data.shipping_methods || [] | ||
for (const sm of shippingMethods) { | ||
await simpleShippingMethodFactory(connection, { ...sm, order_id: order.id }) | ||
} | ||
|
||
const items = data.line_items | ||
const items = data.line_items || [] | ||
for (const item of items) { | ||
await simpleLineItemFactory(connection, { ...item, order_id: id }) | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Question: Deos that mean that the SKU that is used to create the inventory item can be different than the variant SKU? are they meaning different thing?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yes, you will still have access to all the variants stock related fields, however they wont impact behavior if you use the inventory module.